package com.linkedin.thirdeye.detector.email; import java.awt.BasicStroke; import java.awt.Color; import java.io.File; import java.io.IOException; import java.sql.Date; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.TimeZone; import java.util.TreeMap; import org.jfree.chart.ChartFactory; import org.jfree.chart.ChartUtilities; import org.jfree.chart.JFreeChart; import org.jfree.chart.axis.DateAxis; import org.jfree.chart.axis.DateTickUnit; import org.jfree.chart.axis.DateTickUnitType; import org.jfree.chart.plot.IntervalMarker; import org.jfree.chart.plot.Marker; import org.jfree.chart.plot.ValueMarker; import org.jfree.chart.plot.XYPlot; import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer; import org.jfree.data.time.Millisecond; import org.jfree.data.time.RegularTimePeriod; import org.jfree.data.time.TimeSeries; import org.jfree.data.time.TimeSeriesCollection; import org.jfree.data.xy.XYDataset; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.linkedin.thirdeye.api.TimeGranularity; import com.linkedin.thirdeye.client.comparison.Row; import com.linkedin.thirdeye.client.comparison.Row.Metric; import com.linkedin.thirdeye.client.comparison.TimeOnTimeComparisonResponse; import com.linkedin.thirdeye.datalayer.dto.RawAnomalyResultDTO; /** Creates JFreeChart images from Thirdeye Data. */ public class AnomalyGraphGenerator { private static final AnomalyGraphGenerator INSTANCE = new AnomalyGraphGenerator(); private static final int NUM_X_TICKS = 12; private static final Color TRANSPARENT_GRAY = new Color(128, 128, 128, 50); // default grays are // too opaque private static final String CURRENT_LABEL = "Current"; private static final String BASELINE_LABEL = "Last Week"; // TODO make w/w configurable. private static final int DEFAULT_CHART_HEIGHT = 480; private static final int DEFAULT_CHART_WIDTH = 720; private static final Logger LOG = LoggerFactory.getLogger(AnomalyGraphGenerator.class); public static final TimeZone DEFAULT_TIME_ZONE = TimeZone.getTimeZone("America/Los_Angeles"); // Credit for initial skeleton code: // http://www.java2s.com/Code/Java/Chart/JFreeChartLineChartDemo6.htm public static AnomalyGraphGenerator getInstance() { return INSTANCE; } /** * Creates a new graph generator. * @throws Exception */ private AnomalyGraphGenerator() { } /** * Generates a JFreeChart object corresponding to the provided response data. See * {@link #createChart(TimeOnTimeComparisonResponse, TimeGranularity, long, Map). */ public JFreeChart createChart(final TimeOnTimeComparisonResponse data, final TimeGranularity timeGranularity, final long windowMillis, final Map<RawAnomalyResultDTO, String> anomaliesWithLabels) { Set<String> metrics = data.getMetrics(); // TODO error if more than one metric? String metric = metrics.iterator().next(); LOG.info("Creating time series collections for {}", metric); final TimeSeriesCollection dataset = createTimeSeries(data); return createChart(dataset, metric, timeGranularity, windowMillis, anomaliesWithLabels); } /** * Creates a chart containing the current/baseline data (in that order) as well as markers for * each anomaly interval. timeGranularity and windowMillis are used to determine the date format * and spacing for tick marks on the domain (x) axis. */ public JFreeChart createChart(final XYDataset dataset, final String metric, final TimeGranularity timeGranularity, final long windowMillis, final Map<RawAnomalyResultDTO, String> anomaliesWithLabels) { // create the chart... final JFreeChart chart = ChartFactory.createTimeSeriesChart(null, // no chart title for email // image "Date (" + DEFAULT_TIME_ZONE.getID() + ")", // x axis label metric, // y axis label dataset, // data true, // include legend false, // tooltips - n/a if the chart will be saved as an img false // urls - n/a if the chart will be saved as an img ); // get a reference to the plot for further customisation... final XYPlot plot = chart.getXYPlot(); plot.setBackgroundPaint(Color.white); plot.setDomainGridlinesVisible(false); plot.setRangeGridlinesVisible(false); // dashboard webapp currently uses solid blue for current and dashed blue for baseline // (5/2/2016) final XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer(); renderer.setSeriesShapesVisible(0, false); renderer.setSeriesShapesVisible(1, false); renderer.setSeriesPaint(0, Color.BLUE); renderer.setSeriesPaint(1, Color.BLUE); // http://www.java2s.com/Code/Java/Chart/JFreeChartLineChartDemo5showingtheuseofacustomdrawingsupplier.htm // set baseline to be dashed renderer.setSeriesStroke(1, new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 1.0f, new float[] { 2.0f, 6.0f }, 0.0f)); plot.setRenderer(renderer); DateAxis axis = (DateAxis) plot.getDomainAxis(); DateTickUnit dateTickUnit = getDateTickUnit(timeGranularity, windowMillis); SimpleDateFormat dateFormat = getDateFormat(timeGranularity); axis.setDateFormatOverride(dateFormat); axis.setTickUnit(dateTickUnit); axis.setVerticalTickLabels(true); List<Marker> anomalyIntervals = getAnomalyIntervals(anomaliesWithLabels); for (Marker marker : anomalyIntervals) { plot.addDomainMarker(marker); } return chart; } /** * Returns data with series 0 = current and 1 = baseline. This method assumes there is exactly one * metric in the response data. */ private TimeSeriesCollection createTimeSeries(final TimeOnTimeComparisonResponse data) { final TimeSeries baseline = new TimeSeries(BASELINE_LABEL); final TimeSeries current = new TimeSeries(CURRENT_LABEL); for (int i = 0; i < data.getNumRows(); i++) { Row row = data.getRow(i); // Plot the baseline data as an overlay on the corresponding current data point. // long baselineStart = row.getBaselineStart().getMillis(); long currentStart = row.getCurrentStart().getMillis(); Date date = new Date(currentStart); RegularTimePeriod timePeriod = new Millisecond(date); Metric metric = row.getMetrics().get(0); baseline.add(timePeriod, metric.getBaselineValue()); current.add(timePeriod, metric.getCurrentValue()); } final TimeSeriesCollection dataset = new TimeSeriesCollection(); dataset.addSeries(current); dataset.addSeries(baseline); return dataset; } /** * Merges overlapping anomalies and creates JFreeChart Markers for each merged point or interval. */ private List<Marker> getAnomalyIntervals(Map<RawAnomalyResultDTO, String> anomaliesWithLabels) { TreeMap<RawAnomalyResultDTO, String> chronologicalAnomaliesWithLabels = new TreeMap<RawAnomalyResultDTO, String>(new Comparator<RawAnomalyResultDTO>() { @Override public int compare(RawAnomalyResultDTO o1, RawAnomalyResultDTO o2) { int diff = Long.compare(o1.getStartTime(), o2.getStartTime()); if (diff == 0) { diff = o1.compareTo(o2); } return diff; } }); chronologicalAnomaliesWithLabels.putAll(anomaliesWithLabels); Long intervalStart = null; Long intervalEnd = null; // StringBuilder labelBuilder = new StringBuilder(); List<Marker> anomalyMarkers = new ArrayList<>(); for (Entry<RawAnomalyResultDTO, String> entry : chronologicalAnomaliesWithLabels.entrySet()) { RawAnomalyResultDTO anomalyResult = entry.getKey(); // String label = entry.getValue(); Long anomalyStart = anomalyResult.getStartTime(); Long anomalyEnd = anomalyResult.getEndTime(); anomalyEnd = anomalyEnd == null ? anomalyStart : anomalyEnd; if (intervalStart == null || anomalyStart > intervalEnd) { // initialization of intervals if (intervalStart != null) { // create a new marker if this isn't the first element/initialization Marker anomalyMarker = createGraphMarker(intervalStart, intervalEnd, null);// , // labelBuilder.toString()); // labelBuilder.setLength(0); anomalyMarkers.add(anomalyMarker); } intervalStart = anomalyStart; intervalEnd = anomalyEnd; } else { intervalEnd = Math.max(intervalEnd, anomalyEnd); } // if (labelBuilder.length() > 0) { // labelBuilder.append(","); // } // labelBuilder.append(label); } // add the last marker if (intervalStart != null) { Marker anomalyMarker = createGraphMarker(intervalStart, intervalEnd, null);// labelBuilder.toString()); anomalyMarkers.add(anomalyMarker); } // Optional: determine marker positions relative to each other (staggered Left -> Right by // descending // height), aligned so that the marker is on the right // int labelCounter = 0; // for (Marker anomalyMarker : anomalyMarkers) { // anomalyMarker.setLabelAnchor(RectangleAnchor.TOP_LEFT); // anomalyMarker.setLabelTextAnchor(TextAnchor.TOP_RIGHT); // anomalyMarker.setLabelOffset(new RectangleInsets((labelCounter++ // * (DEFAULT_CHART_HEIGHT / (anomalyMarkers.size() + 1)) % DEFAULT_CHART_HEIGHT), 0, 0, 0)); // } return anomalyMarkers; } /** * Returns either a value marker (point) or a interval marker (range) depending on provided * inputs. By default values are gray or transparent gray. * @param intervalStart * @param intervalEnd * @return */ private Marker createGraphMarker(Long intervalStart, Long intervalEnd, String label) { Marker anomalyMarker; if (intervalEnd == null || intervalStart.equals(intervalEnd)) { // Point anomalyMarker = new ValueMarker(intervalStart); anomalyMarker.setPaint(Color.LIGHT_GRAY); } else { // Range anomalyMarker = new IntervalMarker(intervalStart, intervalEnd); anomalyMarker.setPaint(TRANSPARENT_GRAY); } anomalyMarker.setLabel(label); LOG.info("Anomaly marker generated for: {}, {}", intervalStart, intervalEnd); return anomalyMarker; } private SimpleDateFormat getDateFormat(final TimeGranularity timeGranularity) { String format; switch (timeGranularity.getUnit()) { case DAYS: format = "MM/dd"; break; case HOURS: format = "MM/dd-HH'H'"; break; case MILLISECONDS: format = "HH:mm:ss:SSS"; break; case MINUTES: format = "HH:mm"; break; case SECONDS: format = "HH:mm:ss"; break; default: throw new IllegalArgumentException( "Unsupported time unit granularity: " + timeGranularity.getUnit()); } SimpleDateFormat dateFormat = new SimpleDateFormat(format); dateFormat.setTimeZone(DEFAULT_TIME_ZONE); return dateFormat; } private DateTickUnit getDateTickUnit(final TimeGranularity timeGranularity, final long windowMillis) { long windowBuckets = timeGranularity.convertToUnit(windowMillis); int tickSize = (int) Math.ceil(windowBuckets / (double) NUM_X_TICKS); DateTickUnitType unitType; switch (timeGranularity.getUnit()) { case DAYS: unitType = DateTickUnitType.DAY; break; case HOURS: unitType = DateTickUnitType.HOUR; break; case MILLISECONDS: unitType = DateTickUnitType.MILLISECOND; break; case MINUTES: unitType = DateTickUnitType.MINUTE; break; case SECONDS: unitType = DateTickUnitType.SECOND; break; default: throw new IllegalArgumentException( "Unsupported time unit granularity: " + timeGranularity.getUnit()); } return new DateTickUnit(unitType, tickSize); } public void writeChartToFile(JFreeChart chart, String filepath) throws IOException { File file = new File(filepath); LOG.info("Writing to file: {}", file.getAbsolutePath()); ChartUtilities.saveChartAsPNG(file, chart, DEFAULT_CHART_WIDTH, DEFAULT_CHART_HEIGHT); } }