/*
* Copyright 2012 Axel Winkler, Daniel Dunér
*
* This file is part of Daxplore Presenter.
*
* Daxplore Presenter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 2.1 of the License, or
* (at your option) any later version.
*
* Daxplore Presenter 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 Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Daxplore Presenter. If not, see <http://www.gnu.org/licenses/>.
*/
package org.daxplore.presenter.chart.display;
import java.util.LinkedList;
import java.util.List;
import org.daxplore.presenter.chart.data.QueryResult;
import org.daxplore.presenter.chart.data.QueryResultCount;
import org.daxplore.presenter.chart.resources.ChartConfig;
import org.daxplore.presenter.chart.resources.ChartTexts;
import org.daxplore.presenter.client.json.UITexts;
import org.daxplore.presenter.shared.QueryDefinition;
import org.daxplore.presenter.shared.QueryDefinition.QueryFlag;
import com.google.gwt.event.dom.client.MouseMoveEvent;
import com.google.gwt.event.dom.client.MouseMoveHandler;
import com.google.gwt.event.dom.client.MouseOutEvent;
import com.google.gwt.event.dom.client.MouseOutHandler;
import com.googlecode.gchart.client.GChart;
/**
* A basic bar chart, displaying frequencies in different groups. </p>
*
* <p>On the left side of the chart a Y-axis is displayed. The Y axis starts at
* 0% and shows a tick for each 10% and is high enough to cover all the bars in
* the chart. The bars are divided into groups of equal size. The sums of the
* bars in each group is always 100%, with the percentages split among the bars.
* The bars in each group are colored in a specific sequence, which is matched
* by the text in the legend to the right. It is possible to mouse over each
* bar, to get a more detailed description of the bar. Under each group is a
* tick-text, describing what the group is made up of and the size of the
* group.</p>
*
* <p>To create a new chart, you have to supply a Query object, created
* elsewhere. The Query object contains all the data needed to display the
* chart. The loading of the chart is made in two steps. In the constructor, the
* Query data can be used to create the correct number of groups, columns and a
* legend. When the query data has been loaded from the server, the method
* addData is called. In the addData method, the height and position of the
* columns are set. When the columns are set, the ready function is called,
* which updates the chart and adds it to the callback ChartPanel.</p>
*
* @see BarChartCompare
* @see QueryInterface
* @see QueryResult
*/
public class BarChart extends GChartChart {
/**
* The distance between the bars in a group.
*
* Measured in GChart's internal distance system.
*/
private final static double internalGroupSpacing = 0.15;
/**
* The space between different groups, using the internal GChart distance
* system.
*/
protected final static double groupSpacing = 1.5;
/**
* Keeps track of how wide each group is going to be.
*
* <p>The value is computed in the constructor. Measured in GChart's internal
* distance system.</p>
*/
protected double groupWidth;
/**
* Keeps track of the distance between the midpoint of each group.
*
* <p>The value is computed in the constructor. Measured in GChart's internal
* distance system.</p>
*/
protected double groupDistance;
/**
* The height of the highest bar.
*
* <p>Is used when drawing the Y-axis. The default value is 0.1 to begin with,
* so that the Y-axis shows at least 0%-10%.</p>
*/
protected double maxValue = 0.1;
/**
* Keeps track of what position the next groups should be drawn at.
*
* <p>Uses the internal GChart distance system.</p>
*/
protected double currentPosition;
/**
* Keeps track of which group to draw next.
*/
protected int currentGroup;
/**
* Keeps track of how many options the question has.
*
* <p>This corresponds to the number of columns in each group.</p>
*/
protected int questionOptionCount;
/**
* Keeps track of the number of groups to be drawn.
*
* <p>Includes both the selected options and the total item.</p>
*/
protected int groupCount;
/**
* Keeps track of the number of bars to be drawn.
*
* <p>Includes both the selected options and the total item.</p>
*/
protected int barCount;
/**
* The index of the curve used to draw ticks between groups.
*/
protected int betweenGroupsTickCurveIndex;
/**
* The curve index of the padding bar.
*/
protected int paddingBarIndex;
/**
* A list of the groups to be drawn.
*
* <p>In addition to these, there is also the total-item.</p>
*/
protected List<Integer> usedPerspectiveOptions;
/**
* A list of all the primary bars.
*
* <p>In the basic version of this class, there are no secondary bars.</p>
*/
protected LinkedList<BarChartBarPrimary> barListPrimary;
/**
* If a bar is hovered with the mouse, it is stored here.
*
* <p>Keeping track of it makes it possible to restore any changes made to it,
* while it was being hovered.</p>
*/
private ChartBar hoveredBar;
protected ChartConfig chartConfig;
protected int xTickMaxCharacterCount = 13;
/**
* Create a new bar chart.
*
* <p>A query is given, that contains all the data needed to draw the barchart.
* When the query has loaded all the needed data the method addData will be
* called, which finalizes the construction of the chart.</p>
*
* @param query
* The query that this chart will display.
*/
protected BarChart(ChartTexts chartTexts, ChartConfig chartConfig, UITexts uiTexts,
QueryDefinition queryDefinition, boolean printerMode) {
super(chartTexts, uiTexts, queryDefinition);
this.chartConfig = chartConfig;
externalLegend = new ExternalLegend(chartTexts, queryDefinition, printerMode);
usedPerspectiveOptions = queryDefinition.getUsedPerspectiveOptions();
groupCount = usedPerspectiveOptions.size() + (queryDefinition.hasFlag(QueryFlag.TOTAL) ? 1 : 0);
if (groupCount <= 0) {
throw new IllegalArgumentException("Group count = " + groupCount);
}
questionOptionCount = queryDefinition.getQuestionOptionCount();
barCount = groupCount * questionOptionCount;
betweenGroupsTickCurveIndex = barCount;
paddingBarIndex = betweenGroupsTickCurveIndex + groupCount;
groupWidth = questionOptionCount * 1 + (questionOptionCount - 1) * internalGroupSpacing;
groupDistance = groupWidth + groupSpacing;
createCurves(queryDefinition.getQuestionOptionTexts(), printerMode);
setupMouseHandlers();
setupAxes();
}
/**
* Set up the curves, the legend and the axes of the chart.
*
* @param questionOptionTexts
* The texts of the question's options.
*/
protected void createCurves(List<String> questionOptionTexts, boolean printerMode) {
barListPrimary = new LinkedList<>();
for (int groupIndex = 0; groupIndex < groupCount; groupIndex++) {
for (int questionIndex = 0; questionIndex < questionOptionCount; questionIndex++) {
addCurve();
Curve curve = getCurve();
AnnotationLocation hoverLocation = AnnotationLocation.SOUTH;
if(groupCount>1 && groupIndex==0){
hoverLocation = AnnotationLocation.SOUTHEAST;
} else if(groupCount>1 && groupIndex==groupCount-1) {
hoverLocation = AnnotationLocation.SOUTHWEST;
}
addPrimaryBar(new BarChartBarPrimary(chartTexts, curve, getColorSet(questionIndex), printerMode, hoverLocation));
}
}
for (int i = 0; i < groupCount; i++) {
addBetweenGroupsTickCurve();
}
addPaddingCurve();
}
/**
* Adds extra curve to use as end-of-chart padding.
*/
protected void addBetweenGroupsTickCurve() {
addCurve();
Curve curve = getCurve();
Symbol symbol = curve.getSymbol();
symbol.setSymbolType(SymbolType.LINE);
symbol.setHoverAnnotationEnabled(false);
symbol.setHoverSelectionEnabled(false);
symbol.setModelWidth(0);
symbol.setFillThickness(1);
symbol.setBorderColor("black");
symbol.setImageURL("/img/daxplore-chart-tick.gif");
}
/**
* Adds extra curve to use as end-of-chart padding.
*/
protected void addPaddingCurve() {
addCurve();
Symbol symbol = getCurve().getSymbol();
symbol.setBorderStyle("none");
symbol.setBorderWidth(0);
symbol.setSymbolType(SymbolType.VBAR_SOUTHWEST);
symbol.setHoverAnnotationEnabled(false);
symbol.setModelWidth(0);
}
/**
* Set up the appearance of the axes.
*
* <p>When the data is loaded, call setYAxis to give it the Y-axis the correct
* height.</p>
*
* @see setYAxis
*/
private void setupAxes() {
getXAxis().setTickLabelFontSize(12);
getXAxis().setTickLabelThickness(35);
getXAxis().setTickLength(6); // small tick-like gap...
getXAxis().setTickThickness(0); // but with invisible ticks
getXAxis().setAxisMin(0); // keeps first bar on chart
getYAxis().setAxisMin(0);
getYAxis().setTickLabelFormat("#%");
}
/**
* Specify the height and ticks of the Y-Axis.
*
* <p>The Y-Axis is adjusted, so that all bars fit and no unnessecary space
* exists above the bars.</p>
*
* @param maxValue
* The height of the heighest bar.
*/
protected void setYAxis(double maxValue) {
double maxRoundUp = (double) Math.round(maxValue * 10 + 0.49) / 10;
getYAxis().setAxisMax(maxRoundUp);
getYAxis().setTickCount((int) (Math.round(maxRoundUp * 10) + 1));
}
/**
* When a new bar is created, add it here.
*
* <p>It is important to add the curves in left-to-right order. By keeping
* track of the index of the bar, we can find it when we want to add data to
* the curve of a specific index. It also makes it possible to find the bar
* that belongs to a curve that is being hovered.</p>
*
* @param bar
* The bar to be stored in the chart
*/
protected void addPrimaryBar(BarChartBarPrimary bar) {
barListPrimary.add(bar);
}
/**
* Get the primary bar at a specific position in the chart.
*
* The bars are ordered from left to right. If there are secondary bars,
* each index will have one primary bar and one secondary bar.</p>
*
* @param index
* The position of the bar in the chart.
* @return The primary bar at the given position.
*/
protected ChartBar getBarPrimary(int index) {
return barListPrimary.get(index);
}
/**
* Get the bar that belongs to a specific GChart curve.
*
* @param curve
* The curve that the bar belongs to.
* @return The bar that belongs to the given curve.
*/
protected ChartBar getBar(Curve curve) {
for (ChartBar column : barListPrimary) {
if (column.getCurve() == curve) {
return column;
}
}
return null;
}
/**
* Add mouse handlers, to keep track what bars are hovered and unhovered.
*/
private void setupMouseHandlers() {
addMouseMoveHandler(new MouseMoveHandler() {
@Override
public void onMouseMove(MouseMoveEvent event) {
GChart theGChart = (GChart) event.getSource();
Curve curve = theGChart.getTouchedCurve();
boolean updateNeeded = false;
if (hoveredBar != null) {
hoveredBar.unhover();
hoveredBar = null;
updateNeeded = true;
}
if (curve != null) {
hoveredBar = getBar(curve);
if (hoveredBar != null) {
hoveredBar.hover();
updateNeeded = true;
}
}
if (updateNeeded) {
theGChart.update();
}
}
});
addMouseOutHandler(new MouseOutHandler() {
@Override
public void onMouseOut(MouseOutEvent event) {
GChart theGChart = (GChart) event.getSource();
boolean updateNeeded = false;
if (hoveredBar != null) {
hoveredBar.unhover();
hoveredBar = null;
updateNeeded = true;
}
if (updateNeeded) {
theGChart.update();
}
}
});
}
/**
* Add the data and complete the construction of the chart.
*/
public void addData(QueryResultCount queryResult) {
List<String> perspectiveOptionTexts = queryDefinition.getPerspectiveOptionTexts();
currentPosition = 1 + groupSpacing / 2 + internalGroupSpacing;
currentGroup = 0;
for (int perspectiveOption : usedPerspectiveOptions) {
if (currentGroup > 0) {
drawBetweenGroupsTick();
}
if (queryResult.hasData(perspectiveOption)
&& queryResult.getPopulation(perspectiveOption)!=0) { //TODO temporary hack, handle cut-off properly producer
drawBarGroup(perspectiveOptionTexts.get(perspectiveOption), queryResult.getPopulation(perspectiveOption), queryResult.getCountDataPercentages(perspectiveOption));
} else {
drawMissingBarGroup(perspectiveOptionTexts.get(perspectiveOption));
}
currentPosition += groupDistance;
currentGroup++;
}
if (queryDefinition.hasFlag(QueryFlag.TOTAL)) {
if (currentGroup > 0) {
drawBetweenGroupsTick();
}
String totalText = chartTexts.compareWithAll();
if (queryResult.hasTotalDataItemData() && queryResult.getTotalPopulation()!=0) { //TODO temporary hack, handle cut-off properly producer
drawBarGroup(totalText, queryResult.getTotalPopulation(), queryResult.getTotalCountDataPercentages());
} else {
drawMissingBarGroup(totalText);
}
currentPosition += groupDistance;
currentGroup++;
}
currentPosition -= (1 + groupSpacing / 2);
drawPaddingBar();
setYAxis(maxValue);
update();
setVisible(true);
}
/**
* Draw a group of bars.
*
* <p>The group will be drawn at the position given by currentPosition. Each
* bar in the group has a local index, beginning at 0. The height of the bar
* at a specific index, is given by the height of the value at the
* corresponding index in the percentageData array.</p>
*
* @param groupName
* The name of the group, to be displayed under the x-axis tick.
* @param groupSize
* The number of people in the group, to be displayed under the
* x-axis tick.
* @param percentageData
* An array containing the bar data.
*/
private void drawBarGroup(String groupName, int groupSize, double[] percentageData) {
List<String> questionOptionTexts = queryDefinition.getQuestionOptionTexts();
for (int dataIndex = 0; dataIndex < questionOptionCount; dataIndex++) {
BarChartBarPrimary bar = (BarChartBarPrimary) getBarPrimary(currentGroup * questionOptionCount + dataIndex);
if (dataIndex < percentageData.length) {
bar.setDataPoint(currentPosition + dataIndex * (1 + internalGroupSpacing), percentageData[dataIndex]);
bar.setHoverTextStandard(percentageData[dataIndex], questionOptionTexts.get(dataIndex));
if (percentageData[dataIndex] > maxValue) {
maxValue = percentageData[dataIndex];
}
} else {
bar.setDataPoint(currentPosition + dataIndex * (1 + internalGroupSpacing), 0);
}
}
xTickMaxCharacterCount = Math.max(xTickMaxCharacterCount, groupName.length());
String tickText = chartTexts.standardTick(groupName, groupSize);
getXAxis().addTick(currentPosition + groupWidth / 2 - 1, tickText);
}
/**
* Draws a group for which there is no data.
*
* <p>When there is no data to display for this group, call this method. A tick
* and explanatory text is added where the group should have been.</p>
*
* @param groupName
*/
private void drawMissingBarGroup(String groupName) {
String tickText = chartTexts.missingTick(groupName, chartConfig.respondentCountCutoff());
getXAxis().addTick(currentPosition + groupWidth / 2 - 1, tickText.replaceFirst("%s", groupName));
}
/**
* Draw a padding column at the end of the chart.
*
* <p>By adding the padding column, the last bar of the last group won't be
* drawn all the way out to the right. The X-axis will go on for a bit
* beyond the last column, making the chart look a bit better.</p>
*/
protected void drawPaddingBar() {
getCurve(paddingBarIndex).addPoint(currentPosition, 0);
}
protected void drawBetweenGroupsTick() {
getCurve(betweenGroupsTickCurveIndex + currentGroup).addPoint(currentPosition - groupSpacing / 2 - 1.0, -0.01);
getCurve(betweenGroupsTickCurveIndex + currentGroup).addPoint(currentPosition - groupSpacing / 2 - 1.0, 0.01);
}
/**
* {@inheritDoc}
*/
@Override
public int getMinWidth() {
double groupMinWidth = Math.max(xTickMaxCharacterCount * 7.5, questionOptionCount * 20);
return (int) (80 + groupMinWidth * groupCount);
}
}