From c4ec8710614cfb5aff1d2a827ecf6084ea75bf55 Mon Sep 17 00:00:00 2001
From: uhensler <urs.hensler@frentix.com>
Date: Mon, 8 Jul 2019 14:19:38 +0200
Subject: [PATCH] OO-4130: Average of each slider in heat map

---
 .../quality/analysis/GroupedStatistic.java    |  8 +--
 .../quality/analysis/HeatMapStatistic.java    | 38 ++++++++++
 .../analysis/QualityAnalysisService.java      |  2 +
 .../manager/QualityAnalysisServiceImpl.java   |  6 ++
 .../manager/StatisticsCalculator.java         | 26 +++++++
 .../analysis/model/HeatMapStatisticImpl.java  | 59 ++++++++++++++++
 .../analysis/ui/FooterGroupByDataModel.java   | 69 +++++++++++++++++++
 .../analysis/ui/GroupByController.java        | 38 +++++++---
 .../quality/analysis/ui/GroupByDataModel.java | 15 ++--
 .../analysis/ui/HeatMapController.java        | 47 ++++++++++++-
 .../quality/analysis/ui/HeatMapRenderer.java  | 32 ++++++---
 .../quality/analysis/ui/TrendController.java  | 22 ++++++
 .../ui/_i18n/LocalStrings_de.properties       |  1 +
 .../ui/_i18n/LocalStrings_en.properties       |  3 +-
 .../manager/StatisticsCalculatorTest.java     | 23 +++++++
 15 files changed, 354 insertions(+), 35 deletions(-)
 create mode 100644 src/main/java/org/olat/modules/quality/analysis/HeatMapStatistic.java
 create mode 100644 src/main/java/org/olat/modules/quality/analysis/model/HeatMapStatisticImpl.java
 create mode 100644 src/main/java/org/olat/modules/quality/analysis/ui/FooterGroupByDataModel.java

diff --git a/src/main/java/org/olat/modules/quality/analysis/GroupedStatistic.java b/src/main/java/org/olat/modules/quality/analysis/GroupedStatistic.java
index 3199b2e5cb1..cdab2f897a8 100644
--- a/src/main/java/org/olat/modules/quality/analysis/GroupedStatistic.java
+++ b/src/main/java/org/olat/modules/quality/analysis/GroupedStatistic.java
@@ -19,22 +19,16 @@
  */
 package org.olat.modules.quality.analysis;
 
-import org.olat.modules.forms.RubricRating;
-
 /**
  * 
  * Initial date: 11.09.2018<br>
  * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
  *
  */
-public interface GroupedStatistic extends RawGroupedStatistic {
+public interface GroupedStatistic extends RawGroupedStatistic, HeatMapStatistic {
 	
 	public int getSteps();
 	
 	public boolean isRawAvgMaxGood();
-
-	public Double getAvg();
-	
-	public RubricRating getRating();
 	
 }
diff --git a/src/main/java/org/olat/modules/quality/analysis/HeatMapStatistic.java b/src/main/java/org/olat/modules/quality/analysis/HeatMapStatistic.java
new file mode 100644
index 00000000000..9ed67ac3465
--- /dev/null
+++ b/src/main/java/org/olat/modules/quality/analysis/HeatMapStatistic.java
@@ -0,0 +1,38 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.modules.quality.analysis;
+
+import org.olat.modules.forms.RubricRating;
+
+/**
+ * 
+ * Initial date: 8 Jul 2019<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public interface HeatMapStatistic {
+	
+	public Long getCount();
+	
+	public Double getAvg();
+	
+	public RubricRating getRating();
+
+}
diff --git a/src/main/java/org/olat/modules/quality/analysis/QualityAnalysisService.java b/src/main/java/org/olat/modules/quality/analysis/QualityAnalysisService.java
index 2a7b5b5b8ff..47481ac66c0 100644
--- a/src/main/java/org/olat/modules/quality/analysis/QualityAnalysisService.java
+++ b/src/main/java/org/olat/modules/quality/analysis/QualityAnalysisService.java
@@ -112,6 +112,8 @@ public interface QualityAnalysisService {
 	 */
 	public MultiTrendSeries<MultiKey> calculateTrends(AnalysisSearchParameter searchParams,
 			Set<Rubric> rubrics, MultiGroupBy groupBy, TemporalGroupBy temporalGroupBy);
+	
+	public HeatMapStatistic calculateTotal(List<HeatMapStatistic> statistics, Rubric rubric);
 
 	public boolean isInsufficient(Rubric rubric, Double avg);
 }
diff --git a/src/main/java/org/olat/modules/quality/analysis/manager/QualityAnalysisServiceImpl.java b/src/main/java/org/olat/modules/quality/analysis/manager/QualityAnalysisServiceImpl.java
index 317f8846be8..06540c9222d 100644
--- a/src/main/java/org/olat/modules/quality/analysis/manager/QualityAnalysisServiceImpl.java
+++ b/src/main/java/org/olat/modules/quality/analysis/manager/QualityAnalysisServiceImpl.java
@@ -55,6 +55,7 @@ import org.olat.modules.quality.analysis.EvaluationFormView;
 import org.olat.modules.quality.analysis.EvaluationFormViewSearchParams;
 import org.olat.modules.quality.analysis.GroupedStatistic;
 import org.olat.modules.quality.analysis.GroupedStatistics;
+import org.olat.modules.quality.analysis.HeatMapStatistic;
 import org.olat.modules.quality.analysis.MultiGroupBy;
 import org.olat.modules.quality.analysis.MultiKey;
 import org.olat.modules.quality.analysis.MultiTrendSeries;
@@ -342,6 +343,11 @@ public class QualityAnalysisServiceImpl implements QualityAnalysisService {
 				.isPresent();
 	}
 
+	@Override
+	public HeatMapStatistic calculateTotal(List<HeatMapStatistic> statistics, Rubric rubric) {
+		return statisticsCalculator.calculateTotal(statistics, rubric);
+	}
+
 	@Override
 	public boolean isInsufficient(Rubric rubric, Double avg) {
 		RubricRating rating = evaluationFormManager.getRubricRating(rubric, avg);
diff --git a/src/main/java/org/olat/modules/quality/analysis/manager/StatisticsCalculator.java b/src/main/java/org/olat/modules/quality/analysis/manager/StatisticsCalculator.java
index e5ee93d0f0a..e748e277d47 100644
--- a/src/main/java/org/olat/modules/quality/analysis/manager/StatisticsCalculator.java
+++ b/src/main/java/org/olat/modules/quality/analysis/manager/StatisticsCalculator.java
@@ -39,6 +39,7 @@ import org.olat.modules.forms.model.xml.Slider;
 import org.olat.modules.quality.analysis.GroupedStatistic;
 import org.olat.modules.quality.analysis.GroupedStatisticKeys;
 import org.olat.modules.quality.analysis.GroupedStatistics;
+import org.olat.modules.quality.analysis.HeatMapStatistic;
 import org.olat.modules.quality.analysis.MultiKey;
 import org.olat.modules.quality.analysis.MultiTrendSeries;
 import org.olat.modules.quality.analysis.RawGroupedStatistic;
@@ -47,6 +48,7 @@ import org.olat.modules.quality.analysis.TemporalKey;
 import org.olat.modules.quality.analysis.Trend;
 import org.olat.modules.quality.analysis.Trend.DIRECTION;
 import org.olat.modules.quality.analysis.model.GroupedStatisticImpl;
+import org.olat.modules.quality.analysis.model.HeatMapStatisticImpl;
 import org.olat.modules.quality.analysis.model.RawGroupedStatisticImpl;
 import org.olat.modules.quality.analysis.model.TrendImpl;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -114,6 +116,30 @@ public class StatisticsCalculator {
 		return statistic;
 	}
 	
+	HeatMapStatistic calculateTotal(List<HeatMapStatistic> statistics, Rubric rubric) {
+		HeatMapStatistic total;
+		long count = 0;
+		double sumValues = 0;
+		for (HeatMapStatistic statistic : statistics) {
+			if (statistic != null) {
+				Long statisticCount = statistic.getCount();
+				if (statisticCount != null) {
+					count += statisticCount.longValue();
+					sumValues += statisticCount.longValue() * statistic.getAvg().doubleValue();
+				}
+			}
+		}
+		
+		if (count == 0) {
+			total = new HeatMapStatisticImpl(null, null, null);
+		} else {
+			double avg = sumValues / count;
+			RubricRating rating = evaluationFormManager.getRubricRating(rubric, avg);
+			total = new HeatMapStatisticImpl(count, avg, rating);
+		}
+		return total;
+	}
+	
 	List<RawGroupedStatistic> reduceIdentifier(List<RawGroupedStatistic> statisticsList, Set<Rubric> rubrics) {
 		Map<String, Integer> sliderToWeight = rubrics.stream()
 				.map(Rubric::getSliders)
diff --git a/src/main/java/org/olat/modules/quality/analysis/model/HeatMapStatisticImpl.java b/src/main/java/org/olat/modules/quality/analysis/model/HeatMapStatisticImpl.java
new file mode 100644
index 00000000000..9a50b309441
--- /dev/null
+++ b/src/main/java/org/olat/modules/quality/analysis/model/HeatMapStatisticImpl.java
@@ -0,0 +1,59 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.modules.quality.analysis.model;
+
+import org.olat.modules.forms.RubricRating;
+import org.olat.modules.quality.analysis.HeatMapStatistic;
+
+/**
+ * 
+ * Initial date: 8 Jul 2019<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+public class HeatMapStatisticImpl implements HeatMapStatistic {
+	
+	private final Long count;
+	private final Double avg;
+	private final RubricRating rating;
+	
+	public HeatMapStatisticImpl(Long count, Double avg, RubricRating rating) {
+		this.count = count;
+		this.avg = avg;
+		this.rating = rating;
+	}
+
+	@Override
+	public Long getCount() {
+		return count;
+	}
+
+	@Override
+	public Double getAvg() {
+		return avg;
+	}
+
+	@Override
+	public RubricRating getRating() {
+		return rating;
+	}
+	
+
+}
diff --git a/src/main/java/org/olat/modules/quality/analysis/ui/FooterGroupByDataModel.java b/src/main/java/org/olat/modules/quality/analysis/ui/FooterGroupByDataModel.java
new file mode 100644
index 00000000000..e578b5c2464
--- /dev/null
+++ b/src/main/java/org/olat/modules/quality/analysis/ui/FooterGroupByDataModel.java
@@ -0,0 +1,69 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.modules.quality.analysis.ui;
+
+import java.util.List;
+import java.util.Locale;
+
+import org.olat.core.gui.components.form.flexible.impl.elements.table.DefaultFlexiTableDataModel;
+import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableColumnModel;
+import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableFooterModel;
+
+/**
+ * 
+ * Initial date: 8 Jul 2019<br>
+ * @author uhensler, urs.hensler@frentix.com, http://www.frentix.com
+ *
+ */
+class FooterGroupByDataModel extends GroupByDataModel implements FlexiTableFooterModel {
+	
+	private final String footerHeader;
+	private List<?> footerDataValues;
+
+	FooterGroupByDataModel(FlexiTableColumnModel columnsModel, Locale locale, String footerHeader) {
+		super(columnsModel, locale);
+		this.footerHeader = footerHeader;
+	}
+	
+	public void setObjects(List<GroupByRow> objects, List<?> footerDataValues) {
+		super.setObjects(objects);
+		this.footerDataValues = footerDataValues;
+	}
+	
+	@Override
+	public DefaultFlexiTableDataModel<GroupByRow> createCopyWithEmptyList() {
+		return new FooterGroupByDataModel(getTableColumnModel(), locale, footerHeader);
+	}
+
+	@Override
+	public String getFooterHeader() {
+		return footerHeader;
+	}
+
+	@Override
+	public Object getFooterValueAt(int col) {
+		if (footerDataValues != null && col >= GroupByController.DATA_OFFSET) {
+			int pos = col - GroupByController.DATA_OFFSET;
+			return footerDataValues.get(pos);
+		}
+		return null;
+	}
+
+}
diff --git a/src/main/java/org/olat/modules/quality/analysis/ui/GroupByController.java b/src/main/java/org/olat/modules/quality/analysis/ui/GroupByController.java
index dc6906e57d4..7d2538f032a 100644
--- a/src/main/java/org/olat/modules/quality/analysis/ui/GroupByController.java
+++ b/src/main/java/org/olat/modules/quality/analysis/ui/GroupByController.java
@@ -53,6 +53,7 @@ import org.olat.core.gui.components.form.flexible.impl.FormEvent;
 import org.olat.core.gui.components.form.flexible.impl.FormLayoutContainer;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.DefaultFlexiColumnModel;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableColumnModel;
+import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableDataModel;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableDataModelFactory;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.SelectionEvent;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.StaticFlexiCellRenderer;
@@ -97,6 +98,8 @@ import org.springframework.beans.factory.annotation.Autowired;
  */
 public abstract class GroupByController extends FormBasicController implements FilterableController {
 
+	public static final int DATA_OFFSET = 10;
+	
 	private static final String CMD_GROUP_PREFIX = "CLICKED_";
 	private static final String CMD_TREND = "TREND";
 	private static final String[] INSUFFICIENT_KEYS = new String[] {"heatmap.insufficient.select"};
@@ -114,7 +117,6 @@ public abstract class GroupByController extends FormBasicController implements F
 	private SingleSelection temporalGroupEl;
 	private SingleSelection differenceEl;
 	private SingleSelection rubricEl;
-	private GroupByDataModel dataModel;
 	private FlexiTableElement tableEl;
 	private FormLayoutContainer legendLayout;
 	
@@ -252,7 +254,15 @@ public abstract class GroupByController extends FormBasicController implements F
 	protected abstract List<? extends GroupedStatistic> getGroupedStatistcList(MultiKey multiKey);
 
 	protected abstract Set<MultiKey> getStatisticsMultiKeys();
-
+	
+	protected abstract boolean hasFooter();
+	
+	protected abstract void initModel(FlexiTableColumnModel columnsModel);
+	
+	protected abstract FlexiTableDataModel<GroupByRow> getModel();
+	
+	protected abstract void setModelOjects(List<GroupByRow> rows);
+	
 	void setToolComponents(ToolComponents toolComponents) {
 		this.toolComponents = toolComponents;
 		toolComponents.setPrintVisibility(false);
@@ -442,20 +452,21 @@ public abstract class GroupByController extends FormBasicController implements F
 			}
 		}
 		
-		columnIndex = addDataColumns(columnsModel, columnIndex);
+		addDataColumns(columnsModel, DATA_OFFSET);
 		
 		DefaultFlexiColumnModel trendColumn = new DefaultFlexiColumnModel("heatmap.table.title.trend", columnIndex++,
 				CMD_TREND, new StaticFlexiCellRenderer("", CMD_TREND, "o_icon o_icon-lg o_icon_qual_ana_trend", null));
 		trendColumn.setExportable(false);
 		columnsModel.addFlexiColumnModel(trendColumn);
 		
-		dataModel = new GroupByDataModel(columnsModel, getLocale());
+		initModel(columnsModel);
 		if (tableEl != null) flc.remove(tableEl);
-		tableEl = uifactory.addTableElement(getWindowControl(), "table", dataModel, getTranslator(), flc);
+		tableEl = uifactory.addTableElement(getWindowControl(), "table", getModel(), getTranslator(), flc);
 		tableEl.setElementCssClass("o_qual_hm o_qual_trend");
 		tableEl.setEmtpyTableMessageKey("heatmap.empty");
 		tableEl.setNumOfRowsEnabled(false);
 		tableEl.setCustomizeColumns(false);
+		tableEl.setFooter(hasFooter());
 		
 		// legend
 		if (legendLayout != null) flc.remove(legendLayout);
@@ -498,7 +509,7 @@ public abstract class GroupByController extends FormBasicController implements F
 		} else if (source == tableEl && event instanceof SelectionEvent) {
 			SelectionEvent se = (SelectionEvent)event;
 			String cmd = se.getCommand();
-			GroupByRow row = dataModel.getObject(se.getIndex());
+			GroupByRow row = getModel().getObject(se.getIndex());
 			if (CMD_TREND.equals(cmd)) {
 				doShowTrend(ureq, row);
 			} else if (cmd.indexOf(CMD_GROUP_PREFIX) > -1) {
@@ -577,7 +588,10 @@ public abstract class GroupByController extends FormBasicController implements F
 		updateTable(columnConfigs);
 		
 		rows.sort(new GroupNameAlphabeticalComparator());
-		dataModel.setObjects(rows);
+		if (hasFooter()) {
+			
+		}
+		setModelOjects(rows);
 		tableEl.reset(true, true, true);
 	}
 
@@ -656,9 +670,7 @@ public abstract class GroupByController extends FormBasicController implements F
 			if (statistic != null) {
 				Double avg = statistic.getAvg();
 				String identifier = statistic.getIdentifier();
-				Rubric rubric = StringHelper.containsNonWhitespace(identifier)
-					? getRubricByIdentifier(identifier)
-					: getSelectedRubric();
+				Rubric rubric = getRubric(identifier);
 				if (rubric != null) {
 					boolean isInsufficient = analysisService.isInsufficient(rubric, avg);
 					if (isInsufficient) {
@@ -670,6 +682,12 @@ public abstract class GroupByController extends FormBasicController implements F
 		return true;
 	}
 
+	protected Rubric getRubric(String identifier) {
+		return StringHelper.containsNonWhitespace(identifier)
+			? getRubricByIdentifier(identifier)
+			: getSelectedRubric();
+	}
+
 	private Rubric getRubricByIdentifier(String identifier) {
 		for (SliderWrapper sliderWrapper : getSliders()) {
 			if (identifier.equals(sliderWrapper.getIdentifier())) {
diff --git a/src/main/java/org/olat/modules/quality/analysis/ui/GroupByDataModel.java b/src/main/java/org/olat/modules/quality/analysis/ui/GroupByDataModel.java
index 7b0f9d8bcfe..5dd5c0271ee 100644
--- a/src/main/java/org/olat/modules/quality/analysis/ui/GroupByDataModel.java
+++ b/src/main/java/org/olat/modules/quality/analysis/ui/GroupByDataModel.java
@@ -37,7 +37,7 @@ import org.olat.core.gui.components.form.flexible.impl.elements.table.SortableFl
 class GroupByDataModel extends DefaultFlexiTableDataModel<GroupByRow>
 		implements SortableFlexiTableDataModel<GroupByRow> {
 	
-	private final Locale locale;
+	protected final Locale locale;
 	
 	GroupByDataModel(FlexiTableColumnModel columnsModel, Locale locale) {
 		super(columnsModel);
@@ -58,13 +58,14 @@ class GroupByDataModel extends DefaultFlexiTableDataModel<GroupByRow>
 
 	@Override
 	public Object getValueAt(GroupByRow row, int col) {
-		int index = col;
-		if (index < row.getGroupNamesSize()) {
-			return row.getGroupName(index);
+		if (col >= GroupByController.DATA_OFFSET) {
+			int pos = col - GroupByController.DATA_OFFSET;
+			if (pos < row.getStatisticsSize()) {
+				return row.getStatistic(pos);
+			}
 		}
-		index = col - row.getGroupNamesSize();
-		if (index < row.getStatisticsSize()) {
-			return row.getStatistic(index);
+		if (col < row.getGroupNamesSize()) {
+			return row.getGroupName(col);
 		}
 		return null;
 	}
diff --git a/src/main/java/org/olat/modules/quality/analysis/ui/HeatMapController.java b/src/main/java/org/olat/modules/quality/analysis/ui/HeatMapController.java
index b2b164b2ac5..08f1377318c 100644
--- a/src/main/java/org/olat/modules/quality/analysis/ui/HeatMapController.java
+++ b/src/main/java/org/olat/modules/quality/analysis/ui/HeatMapController.java
@@ -28,6 +28,7 @@ import java.util.Set;
 import org.olat.core.gui.UserRequest;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.DefaultFlexiColumnModel;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableColumnModel;
+import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableDataModel;
 import org.olat.core.gui.components.stack.TooledStackedPanel;
 import org.olat.core.gui.control.WindowControl;
 import org.olat.modules.forms.model.xml.Form;
@@ -35,6 +36,7 @@ import org.olat.modules.forms.model.xml.Rubric;
 import org.olat.modules.quality.analysis.AvailableAttributes;
 import org.olat.modules.quality.analysis.GroupedStatistic;
 import org.olat.modules.quality.analysis.GroupedStatistics;
+import org.olat.modules.quality.analysis.HeatMapStatistic;
 import org.olat.modules.quality.analysis.MultiGroupBy;
 import org.olat.modules.quality.analysis.MultiKey;
 import org.olat.modules.quality.analysis.QualityAnalysisService;
@@ -48,7 +50,8 @@ import org.springframework.beans.factory.annotation.Autowired;
  *
  */
 public class HeatMapController extends GroupByController {
-
+	
+	private FooterGroupByDataModel dataModel;
 	private GroupedStatistics<GroupedStatistic> statistics;
 	private int maxCount;
 	
@@ -61,6 +64,7 @@ public class HeatMapController extends GroupByController {
 			TrendDifference trendDifference, String rubricId) {
 		super(ureq, wControl, stackPanel, filterCtrl, evaluationForm, availableAttributes, multiGroupBy,
 				insufficientOnly, temporalGroupBy, trendDifference, rubricId);
+		
 	}
 
 	@Override
@@ -97,8 +101,9 @@ public class HeatMapController extends GroupByController {
 	protected int addDataColumns(FlexiTableColumnModel columnsModel, int columnIndex) {
 		for (SliderWrapper sliderWrapper : getSliders()) {
 			DefaultFlexiColumnModel columnModel = new DefaultFlexiColumnModel("", columnIndex++,
-					new HeatMapRenderer(maxCount));
+					HeatMapRenderer.variableSize(maxCount));
 			columnModel.setHeaderLabel(sliderWrapper.getLabelCode());
+			columnModel.setFooterCellRenderer(HeatMapRenderer.fixedSize());
 			columnsModel.addFlexiColumnModel(columnModel);
 		}
 		return columnIndex;
@@ -119,4 +124,42 @@ public class HeatMapController extends GroupByController {
 	protected Set<MultiKey> getStatisticsMultiKeys() {
 		return statistics.getMultiKeys();
 	}
+
+	@Override
+	protected boolean hasFooter() {
+		return true;
+	}
+
+	@Override
+	protected void initModel(FlexiTableColumnModel columnsModel) {
+		dataModel = new FooterGroupByDataModel(columnsModel, getLocale(), translate("heatmap.footer.title"));
+	}
+
+	@Override
+	protected FlexiTableDataModel<GroupByRow> getModel() {
+		return dataModel;
+	}
+
+	@Override
+	protected void setModelOjects(List<GroupByRow> rows) {
+		List<HeatMapStatistic> footerStatistics = new ArrayList<>();
+		if (!rows.isEmpty()) {
+			int statisticsSize = rows.get(0).getStatisticsSize();
+			for (int i = 0; i < statisticsSize; i++) {
+				Rubric rubric = null;
+				ArrayList<HeatMapStatistic> columnStatistics = new ArrayList<>(rows.size());
+				for (GroupByRow row : rows) {
+					GroupedStatistic columnStatistic = row.getStatistic(i);
+					columnStatistics.add(columnStatistic);
+					if (rubric == null && columnStatistic != null) {
+						rubric = getRubric(columnStatistic.getIdentifier());
+					}
+				}
+				HeatMapStatistic total = analysisService.calculateTotal(columnStatistics, rubric);
+				footerStatistics.add(total);
+			}
+		}
+		dataModel.setObjects(rows, footerStatistics);
+	}
+
 }
diff --git a/src/main/java/org/olat/modules/quality/analysis/ui/HeatMapRenderer.java b/src/main/java/org/olat/modules/quality/analysis/ui/HeatMapRenderer.java
index d2feff31e6d..95f8f32c77f 100644
--- a/src/main/java/org/olat/modules/quality/analysis/ui/HeatMapRenderer.java
+++ b/src/main/java/org/olat/modules/quality/analysis/ui/HeatMapRenderer.java
@@ -28,7 +28,7 @@ import org.olat.core.gui.translator.Translator;
 import org.olat.modules.forms.RubricRating;
 import org.olat.modules.forms.ui.EvaluationFormFormatter;
 import org.olat.modules.forms.ui.RubricAvgRenderer;
-import org.olat.modules.quality.analysis.GroupedStatistic;
+import org.olat.modules.quality.analysis.HeatMapStatistic;
 
 /**
  * 
@@ -39,17 +39,29 @@ import org.olat.modules.quality.analysis.GroupedStatistic;
 public class HeatMapRenderer implements FlexiCellRenderer {
 	
 	private static final int MAX_CIRCLE_SIZE = 18;
-	private int maxCount;
+	private final int maxCount;
+	private final boolean variableSize;
+	
+	public static HeatMapRenderer fixedSize() {
+		return new HeatMapRenderer(0, false);
+	}
+	
+	public static HeatMapRenderer variableSize(int maxCount) {
+		return new HeatMapRenderer(maxCount, true);
+	}
 
-	public HeatMapRenderer(int maxCount) {
+	private HeatMapRenderer(int maxCount, boolean variableSize) {
 		this.maxCount = maxCount;
+		this.variableSize = variableSize;
 	}
 
 	@Override
 	public void render(Renderer renderer, StringOutput target, Object cellValue, int row, FlexiTableComponent source,
 			URLBuilder ubu, Translator translator) {
-		if (cellValue instanceof GroupedStatistic) {
-			GroupedStatistic statistic = (GroupedStatistic) cellValue;
+		if (cellValue instanceof HeatMapStatistic) {
+			HeatMapStatistic statistic = (HeatMapStatistic) cellValue;
+			if (statistic.getCount() == null) return;
+			
 			target.append("<div class='o_circle_container'>");
 			target.append("<div class='o_circle_box' style='width:").append(MAX_CIRCLE_SIZE).append("px;'>");
 			target.append("<div class='o_circle ");
@@ -67,7 +79,7 @@ public class HeatMapRenderer implements FlexiCellRenderer {
 		}
 	}
 
-	public String getColorCss(GroupedStatistic statistic) {
+	public String getColorCss(HeatMapStatistic statistic) {
 		RubricRating rating = statistic.getRating();
 		String colorCss = RubricAvgRenderer.getRatingCssClass(rating);
 		if (colorCss == null) {
@@ -77,8 +89,12 @@ public class HeatMapRenderer implements FlexiCellRenderer {
 	}
 
 	private void appendSize(StringOutput target, Long count) {
-		// The circle areas (not the diameter) are proportional to the count value.
-		double size = MAX_CIRCLE_SIZE * Math.sqrt(count.doubleValue() / maxCount);
+		double size = MAX_CIRCLE_SIZE;
+		if (variableSize) {
+			double circleCount = count.doubleValue() <= maxCount? count.doubleValue(): maxCount;
+			// The circle areas (not the diameter) are proportional to the count value.
+			size = MAX_CIRCLE_SIZE * Math.sqrt(circleCount / maxCount);
+		}
 		target.append(" style='width: ").append(size).append("px; height: ").append(size).append("px'");
 	}
 
diff --git a/src/main/java/org/olat/modules/quality/analysis/ui/TrendController.java b/src/main/java/org/olat/modules/quality/analysis/ui/TrendController.java
index 26d55836570..43060bb6250 100644
--- a/src/main/java/org/olat/modules/quality/analysis/ui/TrendController.java
+++ b/src/main/java/org/olat/modules/quality/analysis/ui/TrendController.java
@@ -26,6 +26,7 @@ import java.util.Set;
 import org.olat.core.gui.UserRequest;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.DefaultFlexiColumnModel;
 import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableColumnModel;
+import org.olat.core.gui.components.form.flexible.impl.elements.table.FlexiTableDataModel;
 import org.olat.core.gui.components.stack.TooledStackedPanel;
 import org.olat.core.gui.control.WindowControl;
 import org.olat.modules.forms.model.xml.Form;
@@ -47,6 +48,7 @@ import org.springframework.beans.factory.annotation.Autowired;
  */
 public class TrendController extends GroupByController {
 
+	private GroupByDataModel dataModel;
 	private MultiTrendSeries<MultiKey> multiTrendSeries;
 	
 	@Autowired
@@ -109,4 +111,24 @@ public class TrendController extends GroupByController {
 		return multiTrendSeries.getIdentifiers();
 	}
 
+	@Override
+	protected boolean hasFooter() {
+		return false;
+	}
+
+	@Override
+	protected void initModel(FlexiTableColumnModel columnsModel) {
+		dataModel = new GroupByDataModel(columnsModel, getLocale());
+	}
+
+	@Override
+	protected FlexiTableDataModel<GroupByRow> getModel() {
+		return dataModel;
+	}
+
+	@Override
+	protected void setModelOjects(List<GroupByRow> rows) {
+		dataModel.setObjects(rows);
+	}
+
 }
diff --git a/src/main/java/org/olat/modules/quality/analysis/ui/_i18n/LocalStrings_de.properties b/src/main/java/org/olat/modules/quality/analysis/ui/_i18n/LocalStrings_de.properties
index eacab65d736..20ca96d9a79 100644
--- a/src/main/java/org/olat/modules/quality/analysis/ui/_i18n/LocalStrings_de.properties
+++ b/src/main/java/org/olat/modules/quality/analysis/ui/_i18n/LocalStrings_de.properties
@@ -62,6 +62,7 @@ heatmap.group2.label=Gruppierung 2
 heatmap.group2=
 heatmap.group3.label=Gruppierung 3
 heatmap.group3=
+heatmap.footer.title=Durchschnitt
 heatmap.insufficient.label=Bewertung
 heatmap.insufficient.select=Nur ungen\u00FCgende
 heatmap.insufficient=
diff --git a/src/main/java/org/olat/modules/quality/analysis/ui/_i18n/LocalStrings_en.properties b/src/main/java/org/olat/modules/quality/analysis/ui/_i18n/LocalStrings_en.properties
index 99a116f56ce..a7d79d282c9 100644
--- a/src/main/java/org/olat/modules/quality/analysis/ui/_i18n/LocalStrings_en.properties
+++ b/src/main/java/org/olat/modules/quality/analysis/ui/_i18n/LocalStrings_en.properties
@@ -56,12 +56,13 @@ heatmap.group.topic.curriculum=Topic curriculum
 heatmap.group.topic.identity=Topic coach
 heatmap.group.topic.organisation=Topic organisation
 heatmap.group.topic.repository=Topic course
-heatmap.group1.label=Groupung 1
+heatmap.group1.label=Grouping 1
 heatmap.group1=
 heatmap.group2.label=Grouping 2
 heatmap.group2=
 heatmap.group3.label=Grouping 3
 heatmap.group3=
+heatmap.footer.title=Average
 heatmap.insufficient.label=Rating
 heatmap.insufficient.select=Only insufficient
 heatmap.insufficient=
diff --git a/src/test/java/org/olat/modules/quality/analysis/manager/StatisticsCalculatorTest.java b/src/test/java/org/olat/modules/quality/analysis/manager/StatisticsCalculatorTest.java
index afd55141c0e..5a9a83725e8 100644
--- a/src/test/java/org/olat/modules/quality/analysis/manager/StatisticsCalculatorTest.java
+++ b/src/test/java/org/olat/modules/quality/analysis/manager/StatisticsCalculatorTest.java
@@ -21,6 +21,8 @@ package org.olat.modules.quality.analysis.manager;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.offset;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
 import static org.olat.test.JunitTestHelper.random;
 
 import java.util.ArrayList;
@@ -35,11 +37,13 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
 import org.olat.modules.forms.EvaluationFormManager;
+import org.olat.modules.forms.RubricRating;
 import org.olat.modules.forms.model.xml.Rubric;
 import org.olat.modules.forms.model.xml.ScaleType;
 import org.olat.modules.forms.model.xml.Slider;
 import org.olat.modules.quality.analysis.GroupedStatistic;
 import org.olat.modules.quality.analysis.GroupedStatistics;
+import org.olat.modules.quality.analysis.HeatMapStatistic;
 import org.olat.modules.quality.analysis.MultiKey;
 import org.olat.modules.quality.analysis.MultiTrendSeries;
 import org.olat.modules.quality.analysis.RawGroupedStatistic;
@@ -49,6 +53,7 @@ import org.olat.modules.quality.analysis.Trend;
 import org.olat.modules.quality.analysis.Trend.DIRECTION;
 import org.olat.modules.quality.analysis.TrendSeries;
 import org.olat.modules.quality.analysis.model.GroupedStatisticImpl;
+import org.olat.modules.quality.analysis.model.HeatMapStatisticImpl;
 import org.olat.modules.quality.analysis.model.RawGroupedStatisticImpl;
 
 /**
@@ -386,5 +391,23 @@ public class StatisticsCalculatorTest {
 		softly.fail("No statistic for %s, %s", multiKey, temporalKey);
 	}
 
+	@Test
+	public void shouldCalculateTotal() {
+		when(evaluationFormManagerMock.getRubricRating(any(), any())).thenReturn(RubricRating.NEUTRAL);
+		
+		Rubric rubric = new Rubric();
+		List<HeatMapStatistic> statistic = new ArrayList<>();
+		statistic.add(new HeatMapStatisticImpl(2l, 3.0, null));
+		statistic.add(new HeatMapStatisticImpl(1l, 1.5, null));
+		statistic.add(new HeatMapStatisticImpl(3l, 5.0, null));
+		statistic.add(new HeatMapStatisticImpl(null, null, null));
+		
+		HeatMapStatistic total = sut.calculateTotal(statistic, rubric);
+		SoftAssertions softly = new SoftAssertions();
+		softly.assertThat(total.getCount()).isEqualTo(6);
+		softly.assertThat(total.getAvg()).isEqualTo(3.75, offset(0.001));
+		softly.assertThat(total.getRating()).isEqualTo(RubricRating.NEUTRAL);
+		softly.assertAll();
+	}
 
 }
-- 
GitLab