/* This file is part of Cyclos (www.cyclos.org). A project of the Social Trade Organisation (www.socialtrade.org). Cyclos 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 2 of the License, or (at your option) any later version. Cyclos 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. You should have received a copy of the GNU General Public License along with Cyclos; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package nl.strohalm.cyclos.controls.reports.statistics.graphs; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; import java.util.List; import java.util.Map; import nl.strohalm.cyclos.controls.ActionContext; import nl.strohalm.cyclos.entities.reports.StatisticalNumber; import nl.strohalm.cyclos.entities.settings.LocalSettings; import nl.strohalm.cyclos.services.stats.StatisticalResultDTO; import nl.strohalm.cyclos.services.stats.general.FilterUsed; import nl.strohalm.cyclos.utils.conversion.KeyToHelpNameConverter; import org.jfree.chart.plot.Marker; import org.jfree.data.Values2D; import org.jfree.data.category.CategoryDataset; import org.jfree.data.general.Dataset; import org.jfree.data.general.PieDataset; import de.laures.cewolf.DatasetProduceException; import de.laures.cewolf.DatasetProducer; import de.laures.cewolf.tooltips.CategoryToolTipGenerator; import de.laures.cewolf.tooltips.PieToolTipGenerator; /** * A wrapper class around the data value object called StatisticalResultDTO, adding functionality for rendering graphs with CeWolf. If showTable and * showGraph are both false, a small table is shown indicating too little data is available. * <p> * The help file is abstracted from the baseKey. If "bla.blah.blaaah" is the baseKey, then the appropriate helpfile will be blaBlahBlaaah.jsp. This is * the helpfile for the table, or for the graph in case no table is shown. The help for the graph in case a table is shown too, is not determined by * this class; it is a general file which is set in the results jsp. * * @author rinke */ public class StatisticalDataProducer implements Serializable, DatasetProducer, CategoryToolTipGenerator, PieToolTipGenerator { private static final long serialVersionUID = 3323675832104771077L; /** * the dataset for the graph. If this is null, the graph hasn't been produced yet */ protected Dataset dataset; /** * an unique identifier for Cewolf. */ private final String producerId; /** * the valueObject with the data */ private final StatisticalResultDTO resultObject; /** * the localSettings, in order to retrieve number formatting for tooltips */ private LocalSettings settings; /** * the actionContext is needed to get language resource bundles */ private ActionContext context; /** * rowHeaders are the final Strings of the headers of the table rows. This class changes any header resource bundle keys into the final Strings * needed. */ private String[] rowHeaders; /** * the graph categories along the x-axis. This corresponds (usually, but not always) to the tables row headers, only that this is a shorter * version (as there is usually less space) */ private final String[] categories; /** * the table column headers. */ private final String[] columnHeaders; /** * the title above the Graph */ private String title; /** * the string along the x-axis of the graph */ private String x_axis; /** * the string along the y-axis of the graph */ private String y_axis; /** * the total value of all sections in a pie chart; */ private Double totalForPie; /** * a factor (in Math.pow(1000,scaleFactor)) for scaling the y-axis */ private byte scaleFactor; // ######################## CONSTRUCTOR ###############################################33 // ######################################################################################## /** * This constructor wraps a dataProducer around the raw DTO object * @param valueObject the raw DTO data object * @param context needed to get the language resource bundle, in order to set table headers and graph strings */ public StatisticalDataProducer(final StatisticalResultDTO valueObject, final ActionContext context) { resultObject = valueObject; producerId = "graphProducer"; final int rows = valueObject.getTableCells().length; categories = new String[resultObject.getCategoriesCount()]; rowHeaders = new String[rows]; final int columns = (rows > 0) ? valueObject.getTableCells()[0].length : 0; columnHeaders = new String[columns]; setResourceStrings(context); /* * The calling of setDataset is complicated, especially for child classes. It MUST be called in the constructor, and cannot be called at first * prior to returning the dataset in produceDataset, because in the jsp cewolf first creates the axis labels before calling produceDataset. As * setDataset also performs the data scaling, the axis labels would not be scaled because they would then be created before scaling occurs. As * the constructor for a child class MUST call the super constructor in its first line, setDataset would be run before the droppedColumns can * be set. This would create wrong scaling also based on columns to be dropped. Therefore, in this constructor, setDataset may only be called * for StatisticalDataproducer itself, not for its child classes. The child classes MUST call setDataset AFTER setting the number of columns * to drop, but still INSIDE their constructor. */ if (this.getClass() == StatisticalDataProducer.class) { setDataset(); } } /** * This private constructor is used when creating a multiple chart. See the {@link #getMultiGraphProducers} method. * * @param original the original statisticalDataProducer from which this constructor is called. * @param index the index of this producer in the array of producers * @param numberOfPoints the number of points (categories) to show in the graph. */ private StatisticalDataProducer(final StatisticalDataProducer original, final int index, final int numberOfPoints) { final boolean byColumn = original.resultObject.getMultiGraph() == StatisticalResultDTO.MultiGraph.BY_COLUMN; final Number[][] data = new Number[numberOfPoints][1]; for (int i = 0; i < numberOfPoints; i++) { data[i][0] = byColumn ? original.getTableCells()[i][index] : original.getTableCells()[index][i]; } resultObject = new StatisticalResultDTO(data); resultObject.setGraphType(original.resultObject.getGraphType()); // any column subHeaders are passed as units for the y-axis, but the parenthesis must be removed. String units = original.getColumnSubHeaders()[index]; if (units.indexOf("(") == 0 && units.lastIndexOf(")") == units.length() - 1) { units = units.substring(1, units.length() - 1); } resultObject.setYAxisUnits(units); producerId = "graphProducer"; categories = new String[numberOfPoints]; System.arraycopy(byColumn ? original.categories : original.getColumnHeaders(), 0, categories, 0, numberOfPoints); columnHeaders = new String[1]; columnHeaders[0] = byColumn ? original.columnHeaders[index] : original.categories[index]; final int numberOfGraphs = byColumn ? original.columnHeaders.length : original.categories.length; x_axis = (index == numberOfGraphs - 1) ? original.getX_axis() : ""; y_axis = byColumn ? original.columnHeaders[index] : original.categories[index]; settings = original.settings; setDataset(); } /** * Cewolf needs this method for generation of tooltips when you hoover the mouse over a graph bar or point If the settings are available, use * them, if not, then unformatted numbers will be shown As tooltips are not essential, and as generation of them should never allow the rendering * of graphs to fail due to exceptions, all is inside a try catch block. */ @Override public String generateToolTip(final CategoryDataset lDataset, final int series, final int lCategories) { try { final Number number = lDataset.getValue(series, lCategories); try { final byte precision = (number instanceof StatisticalNumber) ? ((StatisticalNumber) number).getPrecision() : 0; if (settings != null) { final BigDecimal value = (new BigDecimal(1000).pow(scaleFactor)).multiply(new BigDecimal(number.floatValue())); final String result = settings.getNumberConverterForPrecision(precision).toString(value); return result; } } catch (final Exception e) { // if anything goes wrong, do nothing but continue with String.valueOf } return String.valueOf(number.doubleValue()); } catch (final Exception e) { // if all failed, just return no tooltips return ""; } } /** * Cewolf needs this method for generation of tooltips when you hoover the mouse over a Pie graph section. If the settings are available, use * them, if not, then unformatted numbers will be shown. As tooltips are not essential, and as generation of them should never allow the rendering * of graphs to fail due to exceptions, all is inside a try catch block. * * @param dataset a <code>PieDataset</code> * @param key for identifying the section. * @param pieIndex in case of multiple Pie charts. Not used in cyclos. */ @Override @SuppressWarnings("rawtypes") public String generateToolTip(final PieDataset dataset, final Comparable key, final int pieIndex) { try { final Number number = dataset.getValue(key); try { final byte precision = (number instanceof StatisticalNumber) ? ((StatisticalNumber) number).getPrecision() : 0; if (settings != null) { final BigDecimal value = (new BigDecimal(1000).pow(scaleFactor)).multiply(new BigDecimal(number.floatValue())); final int percentage = (int) Math.round(value.divide(new BigDecimal(getTotalForPie())).doubleValue() * 100); final String result = settings.getNumberConverterForPrecision(precision).toString(value) + " (=" + percentage + "%)"; return result; } } catch (final Exception e) { // if anything goes wrong, do nothing but continue with String.valueOf } final String result = String.valueOf(number.doubleValue()); final int percentage = (int) Math.round(100 * (number.doubleValue() / getTotalForPie())); return result + " (=" + percentage + "%)"; } catch (final Exception e) { // if all failed, just return no tooltips return ""; } } public String getBaseKey() { return resultObject.getBaseKey(); } public String[] getColumnHeaders() { return columnHeaders; } public String[] getColumnSubHeaders() { return resultObject.getColumnSubHeaders(); } /** * gets the domain markers for the graph from the encapsulated data object */ public Marker[] getDomainMarkers() { return resultObject.getDomainMarkers(); } /** * gets the number of filter specs to be displayed with this data. * * @return the number of filters mentioned in the "filters used" box below the graph or table */ public int getFilterCount() { return resultObject.getFiltersUsed().size(); } /** * gets the list with used Filters, to be shown below graph or table (if anything is in it). It manipulates the list also in the following ways: * <ul> * <li>Changes keys to Strings by using the language resource bundle * <li>fills up the list of values with "" so all columns have equal length. * </ul> * * @return a <code>List</code> with <code>FilterUsed</code> objects, one for each relevant filter. */ public List<FilterUsed> getFiltersUsed() { final List<FilterUsed> filterList = resultObject.getFiltersUsed(); // determine max size int maxSize = 0; for (final FilterUsed filterUsed : filterList) { final int size = filterUsed.getValues().size(); if (size > maxSize) { maxSize = size; } } for (final FilterUsed filterUsed : filterList) { // change keys with Strings if (filterUsed.getFilterUse() == FilterUsed.FilterUse.NO_SELECT || filterUsed.getFilterUse() == FilterUsed.FilterUse.NOT_USED) { for (final String key : filterUsed.getValues()) { filterUsed.changeKeyToValue(key, context.message(key)); } } // fill up with "" if needed final int size = filterUsed.getValues().size(); filterUsed.addBlankRows(maxSize - size); } return filterList; } public String getGraphTypeValue() { return resultObject.getGraphType().getValue(); } /** * the height of the graph. This is dynamic because in case of long series, the legend tends to eat up all available space, resulting in a huge * legend and a small graph. * * @return an int which is the graph height. This is read by the jsp. */ public int getHeight() { if (getShowLegend().equals("false")) { return 300; } return (int) Math.round(300 + (getRowHeaders().length * 150.0 / 20.0)); } /** * the help anchor (=name of the #anchor in the help file), generated from the basekey. If the basekey is one.two.three, the helpName will be * oneTwoThree. */ public String getHelpAnchor() { final KeyToHelpNameConverter converter = new KeyToHelpNameConverter(); return converter.toString(getBaseKey()); } public String getHelpFile() { return resultObject.getHelpFile(); } /** * this method takes the object (this), and splits the data up into an array of separated StatisticalDataProducers, where each serie or category * in the original object gets a separate StatisticalDataProducer in the resulting array. So, in case of a StatisticalDataProducer object with 4 * series, the result of this method will be an array with 4 StatisticalDataProducerObjects, each of them having one of the original series as a * single series in its data. This method is needed to be able to produce a combined graph. A combined graph is useful in case the y-axis ranges * of the different series can not reasonably be combined in one single graph with a common y-axis (for example: if series 1 ranges from y=100.000 * to y=200.000, and series 2 ranges from y=1 to y=10.) Graphs can be split into multiple graphs by series, or by categories. In the latter case, * for each category a separate graph is created. * <p> * <b>Notes:</b><br> * The following fields of the resultObject are treated as follows: * <ul> * <li>The <code>dontSwitchXY</code> boolean field is ignored by this method. * <li>The <code>seriesCount</code> field is ignored too, as each graph has per definition one series. * <li>The <code>categoriesCount</code> field sets the number of datapoints on the x-axis. Not setting this just shows all points. * </ul> * * @return an array of separated StatisticalDataProducers, where each serie or category in the original object gets a separate * StatisticalDataProducer in the resulting array */ public StatisticalDataProducer[] getMultiGraphProducers() { if (!isMultiGraph()) { return null; } final int numberOfGraphs; if (resultObject.getMultiGraph() == StatisticalResultDTO.MultiGraph.BY_COLUMN) { numberOfGraphs = getColumnHeaders().length; } else { numberOfGraphs = getRowHeaders().length; } final StatisticalDataProducer[] producerArray = new StatisticalDataProducer[numberOfGraphs]; for (int i = 0; i < numberOfGraphs; i++) { final StatisticalDataProducer item = new StatisticalDataProducer(this, i, resultObject.getCategoriesCount()); producerArray[i] = item; } return producerArray; } /** * This method is needed for Cewolf to produce the graphs. See remarks at the hasExpired method. */ @Override public String getProducerId() { return producerId; } public String[] getRowHeaders() { return rowHeaders; } /** * determines if the legend is shown or not. It is always shown by the pie charts. If a bar or line, then it is shown if there are more than one * series to be displayed. This method is read by the jsp. * * @return a jsp readable string which can either be "true" or "false". */ public String getShowLegend() { if (resultObject.getGraphType() == StatisticalResultDTO.GraphType.PIE) { return "true"; } final Values2D values2D = (Values2D) dataset; if (values2D.getRowCount() == 1) { return "false"; } return "true"; } /** * the subtitle for the graph */ public String getSubTitle() { final String subTitle = resultObject.getSubTitle(); if (subTitle == null) { return ""; } return subTitle; } /** * gets the raw data * @return a 2 dimensional array of <code>Number</code>s with the raw data */ public Number[][] getTableCells() { return resultObject.getTableCells(); } // derived getters and setters, getting their values from the wrapped valueObject public int getTableColumnCount() { return getColumnHeaders().length + 1; } public String getTitle() { if (title == null && context != null) { return context.message(getBaseKey()); } return title; } /** * gets the X-axis label. If the language resource key for this label was not defined, it returns an empty string. It adjusts the label * automatically for any scaling done: if so, the scalefactor is appended to the label, between parenthesis (like "( x 1000)") * @return the x-axis label of the graph */ public String getX_axis() { if (x_axis.startsWith("???") & x_axis.endsWith("???")) {// in case of key not defined return ""; } final StringBuilder sb = new StringBuilder(); sb.append(x_axis); if (resultObject.getScaleFactorX() == null) { if (resultObject.getXAxisUnits().length() > 0) { sb.append(" ").append("(").append(resultObject.getXAxisUnits()).append(")"); } return sb.toString(); } sb.append(" ").append(resultObject.getScaleFactorX()); if (resultObject.getXAxisUnits().length() > 0) { sb.insert(sb.length() - 1, " ").insert(sb.length() - 1, resultObject.getXAxisUnits()); } return sb.toString(); } /** * gets the y-axis label. The label is adjusted for automatic scaling: if scaling was applied, the scalefactor is appended between parenthesis * (like "(x 1000)"). If units were used, the units are placed behind the scale factor number, inside the parenthesis, so like "(x 1000 units)". * @return the y-axis label for the graph. */ public String getY_axis() { if (scaleFactor == 0) { final StringBuilder sb = new StringBuilder(); sb.append(y_axis); if (resultObject.getYAxisUnits().length() > 0) { sb.append(" ").append("(").append(resultObject.getYAxisUnits()).append(")"); } return sb.toString(); } // apply scaling suffix final String factorSign = (scaleFactor < 0) ? resultObject.getYAxisUnits() + "/" : "x"; final int factor = (int) Math.pow(1000, Math.abs(scaleFactor)); final StringBuilder sb = new StringBuilder(); if (settings != null) { sb.append(settings.getNumberConverterForPrecision(0).toString(new BigDecimal(factor))); } else { sb.append(String.valueOf(factor)); } sb.insert(0, factorSign).insert(0, " ("); if (scaleFactor > 0 && resultObject.getYAxisUnits().length() > 0) { sb.append(" ").append(resultObject.getYAxisUnits()); } sb.append(")"); sb.insert(0, y_axis); return sb.toString(); } /** * This method is needed for Cewolf to produce the graphs. It always returns true. If not, DataProducers with equal producerId's return the same * chart (as it is cached). As ProducerId is often equal for a lot of graphs, hasExpired is always set to true. */ @Override @SuppressWarnings("rawtypes") public boolean hasExpired(final Map arg0, final Date arg1) { return true; } public boolean isMultiGraph() { return resultObject.getMultiGraph() != StatisticalResultDTO.MultiGraph.NONE; } /** * if this is false, the applied filters are not shown in the result. The default is true. */ public boolean isShowAppliedFilters() { return resultObject.getFiltersUsed().size() > 0; } public boolean isShowGraph() { return resultObject.getGraphType() != StatisticalResultDTO.GraphType.NONE; } public boolean isShowTable() { return resultObject.isShowTable(); } /** * This method is needed for creating graphs with Cewolf. */ @Override @SuppressWarnings("rawtypes") public Object produceDataset(final Map params) throws DatasetProduceException { return dataset; } public void setSettings(final LocalSettings settings) { this.settings = settings; } /** * gets the value of resultObject.hasErrorBars() for descendant classes * @return true if there are error bars defined */ protected boolean hasErrorBars() { return resultObject.hasErrorBars(); } /** * sets the dataset for the graph. Takes the following actions: * <ul> * <li>checks if a graph is needed * <li>it may switch x and y, depending on the <code>resultObject</code>'s <code>dontSwitchXY</code> property * <li> * generates the dataset * <li>scales it. * </ul> */ protected void setDataset() { if (resultObject.getGraphType() == StatisticalResultDTO.GraphType.PIE) { dataset = GraphDatasetGenerator.generatePieDataset(getRowHeaders(), getTableCells()); return; } if ((isShowGraph()) && (!isMultiGraph())) { final String[] seriesNames; final boolean errorBars = resultObject.hasErrorBars(); final Number[][] dataArray; if (resultObject.isDontSwitchXY()) { // dont switch, exceptional behavior seriesNames = GraphDatasetGenerator.createGraphSeries(getRowHeaders(), resultObject.getSeriesCount()); dataArray = getTableCells(); } else { // switch, default behavior seriesNames = GraphDatasetGenerator.createGraphSeries(getColumnHeaders(), resultObject.getSeriesCount()); dataArray = GraphDatasetGenerator.switchXYData(getTableCells()); } final CategoryDataset dataset = GraphDatasetGenerator.generateDataset(dataArray, seriesNames, categories, errorBars); scaleFactor = calculateScaleFactor(dataset); this.dataset = GraphDatasetGenerator.scaleData(dataset, scaleFactor, errorBars); } } /** * method calculates the scalefactor from the input dataset. Scaling means for example: if the numbers are in the range of millions (so: from 0 to * 10 million), numbers 1 to 10 are shown on the y-axis, and the y-axis label indicates " (x 1.000.000)". Scaling is done per thousands. * * @param dataset * @return the scaleFactor. A value of 1 correspondents with "x1000", a value of 2 with "x1.000.000". */ private byte calculateScaleFactor(final Object dataset) { if (isShowGraph() && (!isMultiGraph())) { return GraphDatasetGenerator.calculateScaleFactor(dataset); } return (byte) 0; } /** * gets the sum value of all pie sections, in order to calculate percentages in tooltips. * @return a double which is the total value of all sections of the pie. */ private double getTotalForPie() { if (totalForPie == null) { double result = 0; for (int i = 0; i < resultObject.getTableCells().length; i++) { result += resultObject.getTableCells()[i][0].doubleValue(); } totalForPie = result; } return totalForPie.doubleValue(); } /** * Takes the keys, and uses these to look up the real strings in the language resource bundle. sets the title, the x- and y-axis labels, and the * categories. This should always be called from the constructor. * * @param context */ private void setResourceStrings(final ActionContext context) { this.context = context; title = context.message(getBaseKey()); x_axis = context.message(getBaseKey() + ".xAxis"); y_axis = context.message(getBaseKey() + ".yAxis"); for (int i = 0; i < rowHeaders.length; i++) { if (resultObject.getRowHeader(i) != null) { rowHeaders[i] = resultObject.getRowHeader(i); if (!resultObject.isDontSwitchXY() && (i < resultObject.getCategoriesCount())) { categories[i] = rowHeaders[i]; } } else { rowHeaders[i] = context.message(resultObject.getRowKey(i), resultObject.getRowKeyArgs(i)); if (!resultObject.isDontSwitchXY() && (i < resultObject.getCategoriesCount())) { categories[i] = context.message(resultObject.getRowKey(i) + ".short"); if (categories[i].startsWith("???") & categories[i].endsWith("???")) {// in case of key not defined categories[i] = ""; } } } } for (int i = 0; i < columnHeaders.length; i++) { if (resultObject.getColumnHeader(i) != null) { columnHeaders[i] = resultObject.getColumnHeader(i); if (resultObject.isDontSwitchXY() && (i < resultObject.getCategoriesCount())) { categories[i] = columnHeaders[i]; } } else { columnHeaders[i] = context.message(resultObject.getColumnKey(i)); if (resultObject.isDontSwitchXY() && (i < resultObject.getCategoriesCount())) { categories[i] = context.message(resultObject.getColumnKey(i) + ".short"); if (categories[i].startsWith("???") & categories[i].endsWith("???")) {// in case of key not defined categories[i] = ""; } } } } } }