GvizHelper.java

/**
 * GvizHelper.java This file is part of WattDepot.
 * <p/>
 * Copyright (C) 2013  Yongwen Xu
 * <p/>
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * <p/>
 * 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 General Public License for more details.
 * <p/>
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.wattdepot.common.util;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;

import javax.xml.datatype.DatatypeConfigurationException;
import javax.xml.datatype.XMLGregorianCalendar;

import org.restlet.resource.ServerResource;
import org.wattdepot.common.domainmodel.InterpolatedValue;
import org.wattdepot.common.domainmodel.InterpolatedValueList;
import org.wattdepot.common.domainmodel.Measurement;
import org.wattdepot.common.domainmodel.MeasurementList;
import org.wattdepot.common.domainmodel.MeasurementType;

import com.google.visualization.datasource.base.DataSourceException;
import com.google.visualization.datasource.base.ReasonType;
import com.google.visualization.datasource.base.TypeMismatchException;
import com.google.visualization.datasource.datatable.ColumnDescription;
import com.google.visualization.datasource.datatable.DataTable;
import com.google.visualization.datasource.datatable.TableRow;
import com.google.visualization.datasource.datatable.value.DateTimeValue;
import com.google.visualization.datasource.datatable.value.ValueType;
import com.google.visualization.datasource.render.JsonRenderer;
import org.wattdepot.common.domainmodel.XYInterpolatedValue;
import org.wattdepot.common.domainmodel.XYInterpolatedValueList;

/**
 * GvizHelper - Utility class that handles Google Visualization using the Google Visualization
 * Datasource library. 
 *
 * * @see <a
 * href="http://code.google.com/apis/chart/interactive/docs/dev/implementing_data_source.html">Google
 * Visualization Datasource API</a>
 *
 * @author Yongwen Xu
 *
 */
public class GvizHelper {
  /** Conversion factor for milliseconds per minute. */
  private static final long MILLISECONDS_PER_MINUTE = 60L * 1000;

  /**
   * @param server
   *          the restlet ServerResource
   * @param type
   *          type of the gviz query string, such as tqx, or tq
   * @return the value of the gviz query string from the request
   */
  public static String getGvizQueryString(ServerResource server, String type) {
    String qs = server.getRequest().getResourceRef().getQueryAsForm().getFirstValue(type);
    try {
      if (qs != null) {
        qs = URLDecoder.decode(qs, "UTF-8");
      }
    }
    catch (UnsupportedEncodingException e) {
      qs = null;
    }
    return qs;
  }

  /**
   * @param resource
   *          server resource object
   * @param tqxString
   *          gviz tqx query string, i.e., request id
   * @param tqString
   *          gviz tq query string, selectable fields
   * @return gviz response 
   */
  public static String getGvizResponse(Object resource, String tqxString, String tqString) {
    DataTable table = null;
    try {
      if (resource instanceof InterpolatedValue) {
        table = getDataTable((InterpolatedValue) resource);
      }
      if (resource instanceof MeasurementList) {
        table = getDataTable((MeasurementList) resource);
      }
      if (resource instanceof InterpolatedValueList) {
        table = getDataTable((InterpolatedValueList) resource);
      }
      if (resource instanceof XYInterpolatedValueList) {
        table = getDataTable((XYInterpolatedValueList) resource);
      }
    }
    catch (DataSourceException e) {
      return getGvizDataErrorResponse(e);
    }

    return getGvizResponseFromDataTable(table, tqxString/*, tqString*/);
  }

  /**
   * @param mValue
   *          mesured value
   * @return gviz DataTable
   * @throws DataSourceException
   *          data source exception
   */
  private static DataTable getDataTable(InterpolatedValue mValue) throws DataSourceException {

    MeasurementType mType = mValue.getMeasurementType();

    DataTable data = new DataTable();

    try {
      data.addColumn(
          new ColumnDescription("Date", ValueType.DATETIME, "Date & Time"));
      data.addColumn(
          new ColumnDescription(mType.getName(), ValueType.NUMBER, mType.toString()));

      TableRow row = new TableRow();

      XMLGregorianCalendar xgcal = DateConvert.convertDate(mValue.getStart());
      row.addCell(new DateTimeValue(convertTimestamp(xgcal)));
      row.addCell(mValue.getValue());

      data.addRow(row);
    }
    catch (TypeMismatchException | DatatypeConfigurationException e) {
      throw new DataSourceException(ReasonType.INTERNAL_ERROR, "Problem adding data to table"); // NOPMD
    }
    return data;
  }

  /**
   * @param mList
   *          measurement list
   * @return gviz data table
   * @throws DataSourceException
   *          data source exception
   */
  private static DataTable getDataTable(MeasurementList mList) throws DataSourceException {
    DataTable data = new DataTable();

    // Sets up the columns requested by any SELECT in the datasource query
    ArrayList<Measurement> measurements = mList.getMeasurements();
    if (!measurements.isEmpty()) {
      String type = measurements.get(0).getMeasurementType();
      try {
        data.addColumn(
            new ColumnDescription("Timestamp", ValueType.DATETIME, "Date & Time"));
        data.addColumn(
            new ColumnDescription(type, ValueType.NUMBER, type));

        for (Measurement measurement : measurements) {
          TableRow row = new TableRow();
          XMLGregorianCalendar xgcal = DateConvert.convertDate(measurement.getDate());
          row.addCell(new DateTimeValue(convertTimestamp(xgcal)));
          row.addCell(measurement.getValue());
          data.addRow(row);
        }
      }
      catch (NumberFormatException e) {
        // String value in database couldn't be converted to a number.
        throw new DataSourceException(ReasonType.INTERNAL_ERROR, "Found bad number in database"); // NOPMD
      }
      catch (TypeMismatchException | DatatypeConfigurationException e) {
        throw new DataSourceException(ReasonType.INTERNAL_ERROR, "Problem adding data to table"); // NOPMD
      }
    }
    return data;
  }

  /**
   * @param mList
   *          measured value list
   * @return gviz data table
   * @throws DataSourceException
   *          data source exception
   */
  private static DataTable getDataTable(InterpolatedValueList mList) throws DataSourceException {
    DataTable data = new DataTable();

    // Sets up the columns requested by any SELECT in the datasource query
    ArrayList<InterpolatedValue> values = mList.getInterpolatedValues();
    if (!values.isEmpty()) {
      String type = values.get(0).getMeasurementType().getUnits();
      try {
        data.addColumn(
            new ColumnDescription("Timestamp", ValueType.DATETIME, "Date & Time"));
        data.addColumn(
            new ColumnDescription(type, ValueType.NUMBER, type));

        for (InterpolatedValue value : values) {
          TableRow row = new TableRow();
          XMLGregorianCalendar xgcal = DateConvert.convertDate(value.getStart());
          row.addCell(new DateTimeValue(convertTimestamp(xgcal)));
          if (value.getValue() != null) {
            row.addCell(value.getValue());
          }
          data.addRow(row);
        }
      }
      catch (NumberFormatException e) {
        // String value in database couldn't be converted to a number.
        throw new DataSourceException(ReasonType.INTERNAL_ERROR, "Found bad number in database"); // NOPMD
      }
      catch (TypeMismatchException | DatatypeConfigurationException e) {
        throw new DataSourceException(ReasonType.INTERNAL_ERROR, "Problem adding data to table"); // NOPMD
      }
    }
    return data;
  }

  /**
   * @param list The XYInterpolatedValueList.
   * @return The gviz data table.
   * @throws DataSourceException if there is a data source problem.
   */
  protected static DataTable getDataTable(XYInterpolatedValueList list) throws DataSourceException {
    DataTable data = new DataTable();

    ArrayList<XYInterpolatedValue> values = list.getValues();
    if (!values.isEmpty()) {
      String xType = values.get(0).getXMeasurementType().getUnits();
      String yType = values.get(0).getYMeasurementType().getUnits();
      try {
        data.addColumn(new ColumnDescription(xType, ValueType.NUMBER, xType));
        data.addColumn(new ColumnDescription(yType, ValueType.NUMBER, yType));

        for (XYInterpolatedValue value : values) {
          TableRow row = new TableRow();
          if (value.getXValue() != null && value.getYValue() != null) {
            row.addCell(value.getXValue());
            row.addCell(value.getYValue());
          }
          data.addRow(row);
        }

      }
      catch (NumberFormatException e) {
        // String value in database couldn't be converted to a number.
        throw new DataSourceException(ReasonType.INTERNAL_ERROR, "Found bad number in database"); // NOPMD
      }
      catch (TypeMismatchException e) {
        throw new DataSourceException(ReasonType.INTERNAL_ERROR, "Problem adding data to table"); // NOPMD
      }
    }
    return data;
  }

  /**
   * @param e
   *          DataSourceException
   * @return the error message
   */
  protected static String getGvizDataErrorResponse(DataSourceException e) {
    return "({status:'error',errors:[{reason:'"
        + e.getReasonType().toString() + "',message:'" + e.getMessageToUser() + "'}]});";
  }

  /**
   * @param table
   *          gviz data table
   * @param tqxString
   *          gviz query string
   * @return the gviz json response
   */
  protected static String getGvizResponseFromDataTable(DataTable table, String tqxString/*, String tqString*/) {
    String response = "google.visualization.Query.setResponse";

    String reqId = null;
    if (tqxString != null) {
      String[] tqxArray = tqxString.split(";");
      for (String s : tqxArray) {
        if (s.contains("reqId")) {
          reqId = s.substring(s.indexOf(":") + 1, s.length());
        }
      }
    }

    String tableString = JsonRenderer.renderDataTable(table, true, true, true).toString();

    response += "({status:'ok',";

    if (reqId != null) {
      response += "reqId:'" + reqId + "',";
    }
    response += "table:" + tableString + "});";

    return response;
  }

  /**
   * Takes an XMLGregorianCalendar (the native WattDepot timestamp format) and returns a 
   * com.ibm.icu.util.GregorianCalendar (<b>note:</b>
   * this is <b>not</b> the same as a java.util.GregorianCalendar!) with the time zone set to GMT,
   * but with the timestamp adjusted by the provided offset.
   *
   * For example, if the input value is 10 AM Hawaii Standard Time (HST is -10 hours from UTC), and
   * the offset is -600 minutes then the output will be 10 AM UTC, effectively subtracting 10 hours
   * from the timestamp (in addition to the conversion between types).
   *
   * This rigamarole is needed because the Google Visualization data source library only accepts
   * timestamps in UTC, and the Annonated Timeline visualization displays the UTC values. Without
   * this conversion, any graphs of meter data recorded in the Hawaii time zone (for instance) would
   * display the data 10 hours later in the graph, which is not acceptable. The timezoneOffset is
   * provided if in the future we want the capability to display data normalized to an arbitrary
   * time zone, rather than just the one the data was collected in.
   *
   * @param timestamp the input XMLGregorianCalendar timestamp
   * @return a com.ibm.icu.util.GregorianCalendar suitable for use by the Google visualization data
   * source library, and normalized by timeZoneOffset but with timeZone field set to GMT.
   */
  private static com.ibm.icu.util.GregorianCalendar convertTimestamp(XMLGregorianCalendar timestamp) {
    // Calendar conversion hell. GViz library uses com.ibm.icu.util.GregorianCalendar, which is
    // different from java.util.GregorianCalendar. So we go from our native format
    // XMLGregorianCalendar -> GregorianCalendar, extract milliseconds since epoch, feed that
    // to the IBM GregorianCalendar.

    int timeZoneOffset = timestamp.getTimezone();

    com.ibm.icu.util.GregorianCalendar gvizCal = new com.ibm.icu.util.GregorianCalendar();

    // Added bonus, DateTimeValue only accepts GregorianCalendars if the timezone is GMT.
    gvizCal.setTimeZone(com.ibm.icu.util.TimeZone.getTimeZone("GMT"));

    java.util.GregorianCalendar standardCal = timestamp.toGregorianCalendar();

    // Add the timeZoneOffset to the epoch time after converting to milliseconds
    gvizCal.setTimeInMillis(standardCal.getTimeInMillis()
        + timeZoneOffset * MILLISECONDS_PER_MINUTE);

    return gvizCal;
  }
}