package org.commcare.graph.view.c3; import android.graphics.Color; import org.commcare.graph.model.AnnotationData; import org.commcare.graph.model.BubblePointData; import org.commcare.graph.model.GraphData; import org.commcare.graph.model.SeriesData; import org.commcare.graph.model.XYPointData; import org.commcare.graph.util.GraphException; import org.commcare.graph.util.GraphUtil; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.Iterator; /** * Data-related configuration for C3. This configuration should be run before * any others, as th data will sometimes affect other configuration. * * Created by jschweers on 11/16/2015. */ public class DataConfiguration extends Configuration { // Actual data: array of arrays, where first element is a string id // and later elements are data, either x values or y values. private final JSONArray mColumns = new JSONArray(); // Hash that pairs up the arrays defined in columns, // y-values-array-id => x-values-array-id private final JSONObject mXs = new JSONObject(); // Hash of y-values id => name for legend private final JSONObject mNames = new JSONObject(); private final JSONObject mXNames = new JSONObject(); // Hash of y-values id => 'y' or 'y2' depending on whether this data // should be plotted against the primary or secondary y axis private final JSONObject mAxes = new JSONObject(); // Hash of y-values id => line, scatter, bar, area, etc. private final JSONObject mTypes = new JSONObject(); // Hash of y-values id => series color private final JSONObject mColors = new JSONObject(); private final JSONObject mLineOpacities = new JSONObject(); private final JSONObject mAreaColors = new JSONObject(); private final JSONObject mAreaOpacities = new JSONObject(); // Array of series that should appear in legend & tooltip private final JSONObject mIsData = new JSONObject(); // Hash of y-values id => point-style string ("circle", "none", "cross", etc.) // Doubles as a record of all user-defined series // (as opposed to series for annotations, etc.) private final JSONObject mPointStyles = new JSONObject(); // Bar graph data: // mBarCount: for the sake of setting x min and max // mBarLabels: the actual labels to display, which are supposed to be the same // for every series, hence the booleans so we only record them once // mBarColors: hash of y-values id => array of colors, with one color for each bar // mBarOpacities: analagous to mBarColors, but for bar opacitiy values private int mBarCount = 0; private final JSONArray mBarLabels = new JSONArray("['']"); private final JSONObject mBarColors = new JSONObject(); private final JSONObject mBarOpacities = new JSONObject(); // Bubble graph data: // y-values id => array of radius values // y-values id => max radius found in that data (or specified by max-radius param) private final JSONObject mRadii = new JSONObject(); private final JSONObject mMaxRadii = new JSONObject(); public DataConfiguration(GraphData data) throws GraphException, JSONException { super(data); // Process data for each series int seriesIndex = 0; for (SeriesData s : mData.getSeries()) { String xID = "x" + seriesIndex; String yID = "y" + seriesIndex; mXs.put(yID, xID); setColumns(xID, yID, s); setColor(yID, s); setName(yID, s); setIsData(yID, s); setPointStyle(yID, s); setType(yID, s); setYAxis(yID, s); seriesIndex++; } // Set up separate variables for features that C3 doesn't support well mVariables.put("areaColors", mAreaColors.toString()); mVariables.put("areaOpacities", mAreaOpacities.toString()); mVariables.put("barColors", mBarColors.toString()); mVariables.put("barOpacities", mBarOpacities.toString()); mVariables.put("isData", mIsData.toString()); mVariables.put("lineOpacities", mLineOpacities.toString()); mVariables.put("maxRadii", mMaxRadii.toString()); mVariables.put("pointStyles", mPointStyles.toString()); mVariables.put("radii", mRadii.toString()); mVariables.put("xNames", mXNames.toString()); // Data-based tweaking of user's configuration and adding system series normalizeBoundaries(); addAnnotations(); addBoundaries(); // Type-specific logic if (mData.getType().equals(GraphUtil.TYPE_TIME)) { mConfiguration.put("xFormat", "%Y-%m-%d %H:%M:%S"); } // Whether or not to show data labels at each point/bar boolean showLabels = Boolean.valueOf(mData.getConfiguration("show-data-labels", "false")); if (showLabels) { mConfiguration.put("labels", true); } // Finally, apply all data to main configuration mConfiguration.put("axes", mAxes); mConfiguration.put("colors", mColors); mConfiguration.put("columns", mColumns); mConfiguration.put("names", mNames); mConfiguration.put("types", mTypes); mConfiguration.put("xs", mXs); mConfiguration.put("groups", getGroups()); } /** * Add annotations, by creating a fake series with data labels turned on. */ private void addAnnotations() throws GraphException, JSONException { JSONObject text = new JSONObject(); int index = 0; for (AnnotationData a : mData.getAnnotations()) { String xID = "annotationsX" + index; String yID = "annotationsY" + index; String description = "annotation '" + a.getAnnotation() + "' at (" + a.getX() + ", " + a.getY() + ")"; text.put(yID, a.getAnnotation()); // Add x value JSONArray xValues = new JSONArray(); xValues.put(xID); if (mData.getType().equals(GraphUtil.TYPE_TIME)) { xValues.put(parseTime(a.getX(), description)); } else { xValues.put(parseDouble(a.getX(), description)); } mColumns.put(xValues); // Add y value JSONArray yValues = new JSONArray(); yValues.put(yID); yValues.put(parseDouble(a.getY(), description)); mColumns.put(yValues); // Configure series mXs.put(yID, xID); mTypes.put(yID, "line"); mAxes.put(yID, "y"); index++; } mVariables.put("annotations", text.toString()); } /** * Create fake series so there's data all the way to the edges of the user-specified * min and max. C3 does tick placement in part based on data, so this will force * it to place ticks based on the user's desired min/max range. */ private void addBoundaries() throws GraphException, JSONException { String xMin = mData.getConfiguration("x-min"); String xMax = mData.getConfiguration("x-max"); // If we don't have user-specified bounds, don't bother. if (xMin == null || xMax == null) { return; } String xID = "boundsX"; if (addBoundary(xID, "boundsY", "y") || addBoundary(xID, "boundsY2", "secondary-y")) { // If at least one y axis had boundaries and therefore a series was created, // now create the matching x values JSONArray xValues = new JSONArray(); xValues.put(xID); if (mData.getType().equals(GraphUtil.TYPE_TIME)) { xValues.put(parseTime(xMin, "x-min")); xValues.put(parseTime(xMax, "x-max")); } else { xValues.put(parseDouble(xMin, "x-min")); xValues.put(parseDouble(xMax, "x-max")); } mColumns.put(xValues); } } /** * Helper for addBoundaries: possibly add a series for either the primary or secondary y axis, * depending on whether or not that axis has a min and max specified. * * @param xID ID of x column to associate with the new series * @param yID ID of y column for new series * @param prefix "y" or "secondary-y" * @return True iff a series was actually created */ private boolean addBoundary(String xID, String yID, String prefix) throws GraphException, JSONException { String min = mData.getConfiguration(prefix + "-min"); String max = mData.getConfiguration(prefix + "-max"); if (min != null && max != null) { mXs.put(yID, xID); mTypes.put(yID, "line"); mAxes.put(yID, prefix.startsWith("secondary") ? "y2" : "y"); JSONArray yValues = new JSONArray(); yValues.put(yID); yValues.put(parseDouble(min, prefix + "-min")); yValues.put(parseDouble(max, prefix + "-max")); mColumns.put(yValues); return true; } return false; } /** * Set up stacked bar graph, if needed. Expects series data to have * already been processed (specifically, expects mTypes to be populated). * * @return JSONArray of configuration for groups, C3's version of stacking */ private JSONArray getGroups() throws JSONException { JSONArray outer = new JSONArray(); JSONArray inner = new JSONArray(); if (mData.getType().equals(GraphUtil.TYPE_BAR) && Boolean.valueOf(mData.getConfiguration("stack", "false"))) { for (Iterator<String> i = mTypes.keys(); i.hasNext(); ) { String key = i.next(); if (mTypes.get(key).equals("bar")) { inner.put(key); } } } else { for (Iterator<String> i = mTypes.keys(); i.hasNext(); ) { String yID = i.next(); if (mTypes.getString(yID).equals("area")) { inner.put(yID); } } } if (inner.length() > 0) { outer.put(inner); } return outer; } /** * For bar charts, set up bar labels and force the x axis min and max so bars are spaced nicely */ private void normalizeBoundaries() throws JSONException { if (mData.getType().equals(GraphUtil.TYPE_BAR)) { mData.setConfiguration("x-min", "0.5"); mData.setConfiguration("x-max", String.valueOf(mBarCount + 0.5)); mBarLabels.put(""); mVariables.put("barLabels", mBarLabels.toString()); // Force all labels to show; C3 will hide some labels if it thinks there are too many. // Skip the first and last elements, which are just spacers, not bars. JSONObject xLabels = new JSONObject(); for (int i = 1; i < mBarLabels.length() - 1; i++) { xLabels.put(String.valueOf(i), mBarLabels.get(i)); } mData.setConfiguration("x-labels", xLabels.toString()); } } /** * Set color for a given series. * * @param yID ID of y-values array to set color * @param s SeriesData from which to pull color */ private void setColor(String yID, SeriesData s) throws JSONException { String barColorJSON = s.getConfiguration("bar-color"); if (barColorJSON != null) { JSONArray requestedColors = new JSONArray(barColorJSON); if (requestedColors.length() > 0) { JSONArray colors = new JSONArray(); JSONArray opacities = new JSONArray(); for (int i = 0; i < requestedColors.length(); i++) { String color = requestedColors.getString(i); color = normalizeColor(color); colors.put(i, "#" + color.substring(3)); opacities.put(getOpacity(color)); } mBarColors.put(yID, colors); mBarOpacities.put(yID, opacities); return; } } String color = s.getConfiguration("line-color", "#ff000000"); color = normalizeColor(color); mColors.put(yID, "#" + color.substring(3)); mLineOpacities.put(yID, getOpacity(color)); String fillBelow = s.getConfiguration("fill-below"); if (fillBelow != null) { fillBelow = normalizeColor(fillBelow); mAreaColors.put(yID, "#" + fillBelow.substring(3)); mAreaOpacities.put(yID, getOpacity(fillBelow)); } } /** * Convert color string to expected format. * * @param color String of format #?(AA)?RRGGBB * @return String of format "#AARRGGBB" */ private String normalizeColor(String color) { if (color.length() % 2 == 0) { color = "#" + color; } if (color.length() == 7) { color = "#ff" + color.substring(1); } return color; } /** * Calculate opacity of given color. * * @param color Color in format "#AARRGGBB" * @return Opacity, which will be between 0 and 1, inclusive */ private double getOpacity(String color) { return Color.alpha(Color.parseColor(color)) / (double)255; } /** * Set up data: x, y, and radius values * * @param xID ID of the x-values array * @param yID ID of the y-values array * @param s The SeriesData providing the data */ private void setColumns(String xID, String yID, SeriesData s) throws GraphException, JSONException { JSONArray xValues = new JSONArray(); JSONArray yValues = new JSONArray(); xValues.put(xID); yValues.put(yID); int barIndex = 0; boolean addBarLabels = mData.getType().equals(GraphUtil.TYPE_BAR) && mBarLabels.length() == 1; JSONArray rValues = new JSONArray(); double maxRadius = parseDouble(s.getConfiguration("max-radius", "0"), "max-radius"); for (XYPointData p : s.getPoints()) { String description = "data (" + p.getX() + ", " + p.getY() + ")"; if (mData.getType().equals(GraphUtil.TYPE_BAR)) { // In CommCare, bar graphs are specified with x as a set of text labels // and y as a set of values. In C3, bar graphs are still basically // of XY graphs, with numeric x and y values. Deal with this by // assigning an arbitrary, evenly-spaced x value to each bar and then // using the user's x values as custom labels. xValues.put(barIndex + 1); mBarCount = Math.max(mBarCount, barIndex + 1); if (addBarLabels) { mBarLabels.put(p.getX()); } } else { if (mData.getType().equals(GraphUtil.TYPE_TIME)) { xValues.put(parseTime(p.getX(), description)); } else { xValues.put(parseDouble(p.getX(), description)); } } yValues.put(parseDouble(p.getY(), description)); // Bubble charts also get a radius if (mData.getType().equals(GraphUtil.TYPE_BUBBLE)) { BubblePointData b = (BubblePointData)p; double r = parseDouble(b.getRadius(), description + " with radius " + b.getRadius()); rValues.put(r); maxRadius = Math.max(maxRadius, r); } barIndex++; } mColumns.put(xValues); mColumns.put(yValues); if (mData.getType().equals(GraphUtil.TYPE_BUBBLE)) { mRadii.put(yID, rValues); mMaxRadii.put(yID, maxRadius); } } /** * Set whether or not point should appear in legend and tooltip. * * @param yID ID of y-values array that is or isn't data * @param s SeriesData from which to pull flag */ private void setIsData(String yID, SeriesData s) throws JSONException { boolean isData = Boolean.valueOf(s.getConfiguration("is-data", "true")); if (isData) { mIsData.put(yID, 1); } } /** * Set series name to display in legend. * * @param yID ID of y-values array that name applies to * @param s SeriesData from which to pull name */ private void setName(String yID, SeriesData s) throws JSONException { String name = s.getConfiguration("name", ""); if (name != null) { mNames.put(yID, name); } mXNames.put(yID, s.getConfiguration("x-name", mData.getConfiguration("x-title", "x"))); } /** * Set shape of points to be drawn for series. * * @param yID ID of y-values that style applies to * @param s SeriesData from which to pull style */ private void setPointStyle(String yID, SeriesData s) throws JSONException { String symbol; if (mData.getType().equals(GraphUtil.TYPE_BAR)) { // point-style doesn't apply to bar charts symbol = "none"; } else if (mData.getType().equals(GraphUtil.TYPE_BUBBLE)) { // point-style doesn't apply to bubble charts, // but this'll make the legend symbol a circle symbol = "circle"; } else { symbol = s.getConfiguration("point-style", "circle").toLowerCase(); } if (symbol.equals("triangle")) { symbol = "triangle-up"; } mPointStyles.put(yID, symbol); } /** * Set series type: line, bar, area, etc. * * @param yID ID of y-values array corresponding with series * @param s SeriesData determining what the type will be */ private void setType(String yID, SeriesData s) throws JSONException { String type = "line"; if (mData.getType().equals(GraphUtil.TYPE_BUBBLE)) { type = "scatter"; } else if (mData.getType().equals(GraphUtil.TYPE_BAR)) { type = "bar"; } else if (s.getConfiguration("fill-below") != null) { type = "area"; } mTypes.put(yID, type); } /** * Set which y axis a series is associated with (primary or secondary). * * @param yID IS of y-values to associate with the axis * @param s SeriesData to pull y axis from * @throws JSONException */ private void setYAxis(String yID, SeriesData s) throws JSONException { boolean isSecondaryY = Boolean.valueOf(s.getConfiguration("secondary-y", "false")); mAxes.put(yID, isSecondaryY ? "y2" : "y"); } }