package org.commcare.android.view; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.Iterator; import java.util.Vector; import org.achartengine.ChartFactory; import org.achartengine.chart.PointStyle; import org.achartengine.model.TimeSeries; import org.achartengine.model.XYMultipleSeriesDataset; import org.achartengine.model.XYSeries; import org.achartengine.renderer.XYMultipleSeriesRenderer; import org.achartengine.renderer.XYSeriesRenderer; import org.commcare.android.models.RangeXYValueSeries; import org.commcare.dalvik.R; import org.commcare.suite.model.graph.AnnotationData; import org.commcare.suite.model.graph.BubblePointData; import org.commcare.suite.model.graph.Graph; import org.commcare.suite.model.graph.GraphData; import org.commcare.suite.model.graph.SeriesData; import org.commcare.suite.model.graph.XYPointData; import org.javarosa.core.model.utils.DateUtils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import android.content.Context; import android.content.Intent; import android.graphics.Color; import android.graphics.Paint.Align; import android.view.View; import android.widget.LinearLayout; /* * View containing a graph. Note that this does not derive from View; call renderView to get a view for adding to other views, etc. * @author jschweers */ public class GraphView { private Context mContext; private int mTextSize; private GraphData mData; private XYMultipleSeriesDataset mDataset; private XYMultipleSeriesRenderer mRenderer; public GraphView(Context context, String title) { mContext = context; mTextSize = (int) context.getResources().getDimension(R.dimen.text_large); mDataset = new XYMultipleSeriesDataset(); mRenderer = new XYMultipleSeriesRenderer(2); // initialize with two scales, to support a secondary y axis mRenderer.setChartTitle(title); mRenderer.setChartTitleTextSize(mTextSize); } /* * Set margins. */ private void setMargins() { int textAllowance = (int) mContext.getResources().getDimension(R.dimen.graph_text_margin); int topMargin = (int) mContext.getResources().getDimension(R.dimen.graph_y_margin); if (!mRenderer.getChartTitle().equals("")) { topMargin += textAllowance; } int rightMargin = (int) mContext.getResources().getDimension(R.dimen.graph_x_margin); if (!mRenderer.getYTitle(1).equals("")) { rightMargin += textAllowance; } int leftMargin = (int) mContext.getResources().getDimension(R.dimen.graph_x_margin); if (!mRenderer.getYTitle().equals("")) { leftMargin += textAllowance; } int bottomMargin = (int) mContext.getResources().getDimension(R.dimen.graph_y_margin); if (!mRenderer.getXTitle().equals("")) { bottomMargin += textAllowance; } mRenderer.setMargins(new int[]{topMargin, leftMargin, bottomMargin, rightMargin}); } private void render(GraphData data) { mData = data; mRenderer.setInScroll(true); for (SeriesData s : data.getSeries()) { renderSeries(s); } renderAnnotations(); configure(); setMargins(); } public Intent getIntent(GraphData data) { render(data); String title = mRenderer.getChartTitle(); if (mData.getType().equals(Graph.TYPE_BUBBLE)) { return ChartFactory.getBubbleChartIntent(mContext, mDataset, mRenderer, title); } if (mData.getType().equals(Graph.TYPE_TIME)) { return ChartFactory.getTimeChartIntent(mContext, mDataset, mRenderer, title, getTimeFormat()); } return ChartFactory.getLineChartIntent(mContext, mDataset, mRenderer, title); } /* * Get a View object that will display this graph. This should be called after making * any changes to graph's configuration, title, etc. */ public View getView(GraphData data) { render(data); // Graph will not render correctly unless it has data, so // add a dummy series if needed. boolean hasPoints = false; Vector<SeriesData> allSeries = data.getSeries(); for (int i = 0; i < allSeries.size() && !hasPoints; i++) { hasPoints = hasPoints || allSeries.get(i).getPoints().size() > 0; } if (!hasPoints) { SeriesData s = new SeriesData(); if (mData.getType().equals(Graph.TYPE_BUBBLE)) { s.addPoint(new BubblePointData("0", "0", "0")); } else if (mData.getType().equals(Graph.TYPE_TIME)) { s.addPoint(new XYPointData(DateUtils.formatDate(new Date(), DateUtils.FORMAT_ISO8601), "0")); } else { s.addPoint(new XYPointData("0", "0")); } s.setConfiguration("line-color", "#00000000"); s.setConfiguration("point-style", "none"); renderSeries(s); } if (mData.getType().equals(Graph.TYPE_BUBBLE)) { return ChartFactory.getBubbleChartView(mContext, mDataset, mRenderer); } if (mData.getType().equals(Graph.TYPE_TIME)) { return ChartFactory.getTimeChartView(mContext, mDataset, mRenderer, getTimeFormat()); } return ChartFactory.getLineChartView(mContext, mDataset, mRenderer); } /** * Fetch date format for displaying time-based x labels. * @return String, a SimpleDateFormat pattern. */ private String getTimeFormat() { return mData.getConfiguration("x-labels-time-format", "yyyy-MM-dd"); } /* * Allow or disallow clicks on this graph - really, on the view generated by getView. */ public void setClickable(boolean enabled) { mRenderer.setClickEnabled(enabled); } /* * Set up a single series. */ private void renderSeries(SeriesData s) { XYSeriesRenderer currentRenderer = new XYSeriesRenderer(); mRenderer.addSeriesRenderer(currentRenderer); configureSeries(s, currentRenderer); XYSeries series = createSeries(Boolean.valueOf(s.getConfiguration("secondary-y", "false")).equals(Boolean.TRUE) ? 1 : 0); if (mData.getType().equals(Graph.TYPE_BUBBLE)) { if (s.getConfiguration("radius-max") != null) { ((RangeXYValueSeries) series).setMaxValue(parseYValue(s.getConfiguration("radius-max"))); } } mDataset.addSeries(series); // Bubble charts will throw an index out of bounds exception if given points out of order Vector<XYPointData> sortedPoints = new Vector<XYPointData>(s.size()); for (XYPointData d : s.getPoints()) { sortedPoints.add(d); } Collections.sort(sortedPoints, new PointComparator()); for (XYPointData p : sortedPoints) { if (mData.getType().equals(Graph.TYPE_BUBBLE)) { BubblePointData b = (BubblePointData) p; ((RangeXYValueSeries) series).add(parseXValue(b.getX()), parseYValue(b.getY()), parseRadiusValue(b.getRadius())); } else if (mData.getType().equals(Graph.TYPE_TIME)) { ((TimeSeries) series).add(parseXValue(p.getX()), parseYValue(p.getY())); } else { series.add(parseXValue(p.getX()), parseYValue(p.getY())); } } } /* * Get layout params for this graph, which assume that graph will fill parent * unless dimensions have been provided via setWidth and/or setHeight. */ public static LinearLayout.LayoutParams getLayoutParams() { return new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT); } /** * Create series appropriate to the current graph type. * @return An XYSeries-derived object. */ private XYSeries createSeries() { return createSeries(0); } /** * Create series appropriate to the current graph type. * @param scaleIndex * @return An XYSeries-derived object. */ private XYSeries createSeries(int scaleIndex) { // TODO: Bubble and time graphs ought to respect scaleIndex, but XYValueSeries // and TimeSeries don't expose the (String title, int scaleNumber) constructor. if (scaleIndex > 0 && !mData.getType().equals(Graph.TYPE_XY)) { throw new IllegalArgumentException("This series does not support a secondary y axis"); } if (mData.getType().equals(Graph.TYPE_TIME)) { return new TimeSeries(""); } if (mData.getType().equals(Graph.TYPE_BUBBLE)) { return new RangeXYValueSeries(""); } return new XYSeries("", scaleIndex); } /* * Set up any annotations. */ private void renderAnnotations() { Vector<AnnotationData> annotations = mData.getAnnotations(); if (!annotations.isEmpty()) { // Create a fake series for the annotations XYSeries series = createSeries(); for (AnnotationData a : annotations) { series.addAnnotation(a.getAnnotation(), parseXValue(a.getX()), parseYValue(a.getY())); } // Annotations won't display unless the series has some data in it series.add(0.0, 0.0); mDataset.addSeries(series); XYSeriesRenderer currentRenderer = new XYSeriesRenderer(); currentRenderer.setAnnotationsTextSize(mTextSize); currentRenderer.setAnnotationsColor(mContext.getResources().getColor(R.drawable.black)); mRenderer.addSeriesRenderer(currentRenderer); } } /* * Apply any user-requested look and feel changes to graph. */ private void configureSeries(SeriesData s, XYSeriesRenderer currentRenderer) { // Default to circular points, but allow other shapes or no points at all String pointStyle = s.getConfiguration("point-style", "circle").toLowerCase(); if (!pointStyle.equals("none")) { PointStyle style = null; if (pointStyle.equals("circle")) { style = PointStyle.CIRCLE; } else if (pointStyle.equals("x")) { style = PointStyle.X; } else if (pointStyle.equals("square")) { style = PointStyle.SQUARE; } else if (pointStyle.equals("triangle")) { style = PointStyle.TRIANGLE; } else if (pointStyle.equals("diamond")) { style = PointStyle.DIAMOND; } currentRenderer.setPointStyle(style); currentRenderer.setFillPoints(true); currentRenderer.setPointStrokeWidth(2); } String lineColor = s.getConfiguration("line-color"); if (lineColor == null) { currentRenderer.setColor(Color.BLACK); } else { currentRenderer.setColor(Color.parseColor(lineColor)); } fillOutsideLine(s, currentRenderer, "fill-above", XYSeriesRenderer.FillOutsideLine.Type.ABOVE); fillOutsideLine(s, currentRenderer, "fill-below", XYSeriesRenderer.FillOutsideLine.Type.BELOW); } /* * Helper function for setting up color fills above or below a series. */ private void fillOutsideLine(SeriesData s, XYSeriesRenderer currentRenderer, String property, XYSeriesRenderer.FillOutsideLine.Type type) { property = s.getConfiguration(property); if (property != null) { XYSeriesRenderer.FillOutsideLine fill = new XYSeriesRenderer.FillOutsideLine(type); fill.setColor(Color.parseColor(property)); currentRenderer.addFillOutsideLine(fill); } } /* * Configure graph's look and feel based on default assumptions and user-requested configuration. */ private void configure() { // Default options mRenderer.setBackgroundColor(mContext.getResources().getColor(R.drawable.white)); mRenderer.setMarginsColor(mContext.getResources().getColor(R.drawable.white)); mRenderer.setLabelsColor(mContext.getResources().getColor(R.color.grey_darker)); mRenderer.setXLabelsColor(mContext.getResources().getColor(R.color.grey_darker)); mRenderer.setYLabelsColor(0, mContext.getResources().getColor(R.color.grey_darker)); mRenderer.setYLabelsColor(1, mContext.getResources().getColor(R.color.grey_darker)); mRenderer.setXLabelsAlign(Align.CENTER); mRenderer.setYLabelsAlign(Align.RIGHT); mRenderer.setYLabelsAlign(Align.LEFT, 1); mRenderer.setYLabelsPadding(10); mRenderer.setYAxisAlign(Align.RIGHT, 1); mRenderer.setAxesColor(mContext.getResources().getColor(R.color.grey_lighter)); mRenderer.setLabelsTextSize(mTextSize); mRenderer.setAxisTitleTextSize(mTextSize); mRenderer.setApplyBackgroundColor(true); mRenderer.setShowLegend(false); mRenderer.setShowGrid(true); // User-configurable options mRenderer.setXTitle(mData.getConfiguration("x-title", "")); mRenderer.setYTitle(mData.getConfiguration("y-title", "")); mRenderer.setYTitle(mData.getConfiguration("secondary-y-title", ""), 1); if (mData.getConfiguration("x-min") != null) { mRenderer.setXAxisMin(parseXValue(mData.getConfiguration("x-min"))); } if (mData.getConfiguration("y-min") != null) { mRenderer.setYAxisMin(parseYValue(mData.getConfiguration("y-min"))); } if (mData.getConfiguration("secondary-y-min") != null) { mRenderer.setYAxisMin(parseYValue(mData.getConfiguration("secondary-y-min")), 1); } if (mData.getConfiguration("x-max") != null) { mRenderer.setXAxisMax(parseXValue(mData.getConfiguration("x-max"))); } if (mData.getConfiguration("y-max") != null) { mRenderer.setYAxisMax(parseYValue(mData.getConfiguration("y-max"))); } if (mData.getConfiguration("secondary-y-max") != null) { mRenderer.setYAxisMax(parseYValue(mData.getConfiguration("secondary-y-max")), 1); } String showGrid = mData.getConfiguration("show-grid", "true"); if (Boolean.valueOf(showGrid).equals(Boolean.FALSE)) { mRenderer.setShowGridX(false); mRenderer.setShowGridY(false); } String showAxes = mData.getConfiguration("show-axes", "true"); if (Boolean.valueOf(showAxes).equals(Boolean.FALSE)) { mRenderer.setShowAxes(false); } // Labels boolean hasXLabels = configureLabels("x-labels"); boolean hasYLabels = configureLabels("y-labels"); boolean hasSecondaryYLabels = configureLabels("secondary-y-labels"); boolean showLabels = hasXLabels || hasYLabels || hasSecondaryYLabels; mRenderer.setShowLabels(showLabels); mRenderer.setShowTickMarks(showLabels); boolean panAndZoom = Boolean.valueOf(mData.getConfiguration("zoom", "false")).equals(Boolean.TRUE); mRenderer.setPanEnabled(panAndZoom, panAndZoom); mRenderer.setZoomEnabled(panAndZoom, panAndZoom); mRenderer.setZoomButtonsVisible(panAndZoom); } /** * Parse given string into Double for AChartEngine. * @param value * @return */ private Double parseXValue(String value) { if (mData.getType().equals(Graph.TYPE_TIME)) { return Double.valueOf(DateUtils.parseDateTime(value).getTime()); } return Double.valueOf(value); } /** * Parse given string into Double for AChartEngine. * @param value * @return */ private Double parseYValue(String value) { return Double.valueOf(value); } /** * Parse given string into Double for AChartEngine. * @param value * @return */ private Double parseRadiusValue(String value) { return Double.valueOf(value); } /** * Customize labels. * @param key One of "x-labels", "y-labels", "secondary-y-labels" * @return True if any labels at all will be displayed. */ private boolean configureLabels(String key) { boolean hasLabels = false; // The labels setting might be a JSON array of numbers, // a JSON object of number => string, or a single number String labelString = mData.getConfiguration(key); if (labelString != null) { try { // Array: label each given value JSONArray labels = new JSONArray(labelString); setLabelCount(key, 0); for (int i = 0; i < labels.length(); i++) { String value = labels.getString(i); addTextLabel(key, parseXValue(value), value); } hasLabels = labels.length() > 0; } catch (JSONException je) { // Assume try block failed because labelString isn't an array. // Try parsing it as an object. try { // Object: each keys is a location on the axis, // and the value is the text with which to label it JSONObject labels = new JSONObject(labelString); setLabelCount(key, 0); Iterator i = labels.keys(); while (i.hasNext()) { String location = (String) i.next(); addTextLabel(key, parseXValue(location), labels.getString(location)); hasLabels = true; } } catch (JSONException e) { // Assume labelString is just a scalar, which // represents the number of labels the user wants. Integer count = Integer.valueOf(labelString); setLabelCount(key, count); hasLabels = count != 0; } } } return hasLabels; } /** * Helper for configureLabels. Adds a label to the appropriate axis. * @param key One of "x-labels", "y-labels", "secondary-y-labels" * @param location Point on axis to add label * @param text String for label */ private void addTextLabel(String key, Double location, String text) { if (isXKey(key)) { mRenderer.addXTextLabel(location, text); } else { int scaleIndex = getScaleIndex(key); if (mRenderer.getYAxisAlign(scaleIndex) == Align.RIGHT) { text = " " + text; } mRenderer.addYTextLabel(location, text, scaleIndex); } } /** * Helper for configureLabels. Sets desired number of labels for the appropriate axis. * AChartEngine will then determine how to space the labels. * @param key One of "x-labels", "y-labels", "secondary-y-labels" * @param value Number of labels */ private void setLabelCount(String key, int value) { if (isXKey(key)) { mRenderer.setXLabels(value); } else { mRenderer.setYLabels(value); } } /** * Helper for turning key into scale. * @param key Something like "x-labels" or "y-secondary-labels" * @return Index for passing to AChartEngine functions that accept a scale */ private int getScaleIndex(String key) { return key.contains("secondary") ? 1 : 0; } /** * Helper for parsing axis from configuration key. * @param key Something like "x-min" or "y-labels" * @return True iff key is relevant to x axis */ private boolean isXKey(String key) { return key.startsWith("x-"); } /** * Comparator to sort XYPointData-derived objects by x value. * @author jschweers */ private class PointComparator implements Comparator<XYPointData> { @Override public int compare(XYPointData lhs, XYPointData rhs) { return parseXValue(lhs.getX()).compareTo(parseXValue(rhs.getX())); } } }