// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Xml.Linq; using CSVStats; using PerfReportTool; namespace PerfSummaries { class TableUtil { public static string FormatStatName(string inStatName) { // We add a zero width space so stats are split over multiple lines return inStatName.Replace("/", "/"); } public static string SanitizeHtmlString(string str) { return str.Replace("<", "<").Replace(">", ">"); } public static string SafeTruncateHtmlTableValue(string inValue, int maxLength) { if (inValue.StartsWith("")) { // Links require special handling. Only truncate what's inside int openAnchorEndIndex = inValue.IndexOf(">"); int closeAnchorStartIndex = inValue.IndexOf(""); if (openAnchorEndIndex > 2 && closeAnchorStartIndex > openAnchorEndIndex) { string anchor = inValue.Substring(0, openAnchorEndIndex + 1); string text = inValue.Substring(openAnchorEndIndex + 1, closeAnchorStartIndex - (openAnchorEndIndex + 1)); if (text.Length > maxLength) { text = SanitizeHtmlString(text.Substring(0, maxLength)) + "..."; } return anchor + text + ""; } } return SanitizeHtmlString(inValue.Substring(0, maxLength)) + "..."; } } // Allows overriding style aspects of the summary table HTML. // New overrides should be filled in here with valid defaults. class SummaryTableStyle { // Max width of the column header. // Used when the table is not transposed. public int maxColumnHeaderWidth = 220; public int minColumnHeaderWidth = 75; } class SummarySectionBoundaryInfo { public SummarySectionBoundaryInfo(string inStatName, string inStartToken, string inEndToken, int inLevel, bool inInCollatedTable, bool inInFullTable) { statName = inStatName; startToken = inStartToken; endToken = inEndToken; level = inLevel; inCollatedTable = inInCollatedTable; inFullTable = inInFullTable; } public string statName; public string startToken; public string endToken; public int level; public bool inCollatedTable; public bool inFullTable; }; class SummaryTableInfo { public SummaryTableInfo(XElement tableElement, Dictionary> substitutionsDict, string[] appendList, string[] rowSortAppendList, XmlVariableMappings variableMappings ) { string rowSortStr = tableElement.GetSafeAttribute(variableMappings, "rowSort"); if (rowSortStr != null) { rowSortList.AddRange(rowSortStr.Split(',').Select(s => s.Trim())); ApplySubstitutionsToList(rowSortList, substitutionsDict); } if (rowSortAppendList != null) { rowSortList.AddRange(rowSortAppendList); } weightByColumn = tableElement.GetSafeAttribute(variableMappings, "weightByColumn"); if (weightByColumn != null) { weightByColumn = weightByColumn.ToLower(); } XElement filterEl = tableElement.Element("filter"); if (filterEl != null) { columnFilterList.AddRange(filterEl.GetValue(variableMappings).Split(',').Select(s => s.Trim())); ApplySubstitutionsToList(columnFilterList, substitutionsDict); } if (appendList != null) { columnFilterList.AddRange(appendList); } bReverseSortRows = tableElement.GetSafeAttribute(variableMappings, "reverseSortRows", false); bScrollableFormatting = tableElement.GetSafeAttribute(variableMappings, "scrollableFormatting", false); tableColorizeMode = tableElement.GetSafeAttribute(variableMappings, "colorizeMode", tableColorizeMode); dateCollationVisibility = tableElement.GetSafeAttribute(variableMappings, "collatedDateVisibility", dateCollationVisibility); stringCollationVisibility = tableElement.GetSafeAttribute(variableMappings, "collatedStringVisibility", stringCollationVisibility); columnSortMode = tableElement.GetSafeAttribute(variableMappings, "columnSortMode", columnSortMode); // Table style overrides summaryTableStyle.maxColumnHeaderWidth = tableElement.GetSafeAttribute("maxColumnHeaderWidth", summaryTableStyle.maxColumnHeaderWidth); summaryTableStyle.minColumnHeaderWidth = tableElement.GetSafeAttribute("minColumnHeaderWidth", summaryTableStyle.minColumnHeaderWidth); statThreshold = tableElement.GetSafeAttribute(variableMappings, "statThreshold", 0.0f); hideStatPrefix = tableElement.GetSafeAttribute(variableMappings, "hideStatPrefix"); foreach (XElement sectionBoundaryEl in tableElement.Elements("sectionBoundary")) { if (sectionBoundaryEl != null) { string statName = ApplySubstitution(sectionBoundaryEl.GetSafeAttribute(variableMappings, "statName"), substitutionsDict); SummarySectionBoundaryInfo sectionBoundary = new SummarySectionBoundaryInfo( statName, sectionBoundaryEl.GetSafeAttribute(variableMappings, "startToken"), sectionBoundaryEl.GetSafeAttribute(variableMappings, "endToken"), sectionBoundaryEl.GetSafeAttribute(variableMappings, "level", 0), sectionBoundaryEl.GetSafeAttribute(variableMappings, "inCollatedTable", true), sectionBoundaryEl.GetSafeAttribute(variableMappings, "inFullTable", true) ); sectionBoundaries.Add(sectionBoundary); } } } private void ApplySubstitutionsToList(List list, Dictionary> substitutionsDict) { if (substitutionsDict == null) { return; } for (int i = 0; i < list.Count; i++) { if (substitutionsDict.TryGetValue(list[i].ToLowerInvariant(), out List replaceList)) { list.RemoveAt(i); list.InsertRange(i, replaceList); i += replaceList.Count - 1; } } } private string ApplySubstitution(string str, Dictionary> substitutionsDict) { if (substitutionsDict == null) { return str; } if (substitutionsDict.TryGetValue(str.ToLowerInvariant(), out List replaceList)) { if (replaceList.Count>1 || replaceList.Count == 0) { // This method doesn't support one-to-many remapping, so ignore return str; } return replaceList[0]; } return str; } public SummaryTableInfo(string filterListStr, string rowSortStr) { columnFilterList.AddRange(filterListStr.Split(',').Select(s => s.Trim())); rowSortList.AddRange(rowSortStr.Split(',').Select(s => s.Trim())); } public SummaryTableInfo() { } public List rowSortList = new List(); public List columnFilterList = new List(); public List sectionBoundaries = new List(); public bool bReverseSortRows; public bool bScrollableFormatting; public TableColorizeMode tableColorizeMode = TableColorizeMode.Budget; public DateCollationVisibility dateCollationVisibility = DateCollationVisibility.Newest; public StringCollationVisibility stringCollationVisibility = StringCollationVisibility.Auto; public TableColumnSortMode columnSortMode = TableColumnSortMode.Default; public SummaryTableStyle summaryTableStyle = new SummaryTableStyle(); public float statThreshold; public string hideStatPrefix = null; public string weightByColumn = null; } class SummaryTableColumnFormatInfoCollection { public SummaryTableColumnFormatInfoCollection(XElement element) { foreach (XElement child in element.Elements("columnInfo")) { columnFormatInfoList.Add(new SummaryTableColumnFormatInfo(child)); } } public SummaryTableColumnFormatInfo GetFormatInfo(string columnName) { string lowerColumnName = columnName.ToLower(); if (lowerColumnName.StartsWith("avg ") || lowerColumnName.StartsWith("min ") || lowerColumnName.StartsWith("max ")) { lowerColumnName = lowerColumnName.Substring(4); } foreach (SummaryTableColumnFormatInfo columnInfo in columnFormatInfoList) { int wildcardIndex = columnInfo.name.IndexOf('*'); if (wildcardIndex == -1) { if (columnInfo.name == lowerColumnName) { return columnInfo; } } else { string prefix = columnInfo.name.Substring(0, wildcardIndex); if (lowerColumnName.StartsWith(prefix)) { return columnInfo; } } } return defaultColumnInfo; } public static SummaryTableColumnFormatInfo DefaultColumnInfo { get { return defaultColumnInfo; } } List columnFormatInfoList = new List(); static SummaryTableColumnFormatInfo defaultColumnInfo = new SummaryTableColumnFormatInfo(); }; enum TableColorizeMode { Off, // No coloring. Budget, // Colorize based on defined budgets. No coloring for stats without defined budgets. Auto, // Auto calculate based on other values in the column. }; enum TableColumnSortMode { Default, // Default sort (uses specified filter order, alphabetical wildcard resolution) WildcardSortByAvg, // When resolving wildcards, sort results by column average value WildcardSortByMax, // When resolving wildcards, sort results by column max value SortByAvg, // Sort all columns by average value (ignores specified filter order) SortByMax, // Sort all columns by max value (ignores specified filter order) }; enum AutoColorizeMode { Off, HighIsBad, LowIsBad, }; enum ColumnAggregateType { None, Avg, Min, Max }; enum DiffRowFrequency { None, Alternating, AfterEachPair, All }; enum DateCollationVisibility { Hide, Newest, Oldest }; enum StringCollationVisibility { Show, Hide, Auto }; class SummaryTableColumnFormatInfo { public SummaryTableColumnFormatInfo() { name = "Default"; maxStringLength = Int32.MaxValue; maxStringLengthCollated = Int32.MaxValue; } public SummaryTableColumnFormatInfo(XElement element) { name = element.Attribute("name").Value.ToLower(); string autoColorizeStr = element.GetSafeAttribute("autoColorize", "highIsBad").ToLower(); var modeList = Enum.GetValues(typeof(AutoColorizeMode)); foreach (AutoColorizeMode mode in modeList) { if (mode.ToString().ToLower() == autoColorizeStr) { autoColorizeMode = mode; break; } } numericFormat = element.GetSafeAttribute("numericFormat"); maxStringLength = element.GetSafeAttribute("maxStringLength", Int32.MaxValue ); maxStringLengthCollated = element.GetSafeAttribute("maxStringLengthCollated", Int32.MaxValue ); if (maxStringLengthCollated == Int32.MaxValue) { maxStringLengthCollated = maxStringLength; } noWrap = element.GetSafeAttribute("noWrap") == "true"; if (IsDate()) { dateFormat = element.GetSafeAttribute("dateFormat"); string timeZoneId = element.GetSafeAttribute("dateTimeZoneId"); dateTimeZone = TimeZoneInfo.Utc; if (timeZoneId != null) { // Matches time zones in HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Time Zones. // Will raise an exception if Id is invalid. dateTimeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); } } includeValueWithBucketName = element.GetSafeAttribute("includeValueWithBucketName", true); string bucketNamesString = element.GetSafeAttribute("valueBucketNames"); if (bucketNamesString != null) { bucketNames = bucketNamesString.Split(',').ToList(); } string bucketThresholdsString = element.GetSafeAttribute("valueBucketThresholds"); if (bucketThresholdsString != null) { bucketThresholds = bucketThresholdsString.Split(',').Select(valStr => { if (float.TryParse(valStr, out float value)) { return value; } return 0.0f; }).ToList(); } colourThresholdList = ColourThresholdList.ReadColourThresholdListXML(element.Element("colourThresholds"), null); } public bool IsDate() => numericFormat == "date"; public AutoColorizeMode autoColorizeMode = AutoColorizeMode.HighIsBad; public string name; public bool noWrap = false; public string numericFormat; public int maxStringLength; public int maxStringLengthCollated; // If we should display the actual value in parenthesis next to the bucket name (if a bucket exists). public bool includeValueWithBucketName = true; // The name of each bucket. The name is indexed by the threshold. public List bucketNames = new List(); // The value thresholds that correspond to each bucket. If a name doesn't exist for a threshold, the last bucket name is used. public List bucketThresholds = new List(); // Colour thresholds override for this column. public ColourThresholdList colourThresholdList = null; // Date properties public string dateFormat = null; public TimeZoneInfo dateTimeZone = null; }; class SummaryTableColumn { public string name; public bool isNumeric = false; public string displayName; public bool isRowWeightColumn = false; public DiffRowFrequency diffRowFrequency = DiffRowFrequency.None; public bool bShowOnlyDiffRows = false; public bool isCountColumn = false; // Column header tooltip. Displayed when hovering over the header. public string tooltip = null; // Multiplied with the colour of each cell to modify the colour (including header). public Colour columnColourModifier = null; public ColumnAggregateType aggregateType = ColumnAggregateType.None; public SummaryTableColumn aggregateBaseColumn = null; // For avg/min/max columns, this will point back to the base(avg) column List doubleValues = new List(); List stringValues = new List(); List toolTips = new List(); // Per cell colour modifiers. Dictionary colourModifiers = new Dictionary(); public SummaryTableElement.Type elementType; public SummaryTableColumnFormatInfo formatInfo = null; List colourThresholds = new List(); ColourThresholdList colourThresholdOverride = null; public SummaryTableColumn( string inName, bool inIsNumeric, string inDisplayName, bool inIsRowWeightColumn, SummaryTableElement.Type inElementType, SummaryTableColumnFormatInfo inFormatInfo = null, string inTooltip = null, Colour inColumnColourModifier = null, ColumnAggregateType inAggregateType = ColumnAggregateType.None, SummaryTableColumn inAggregateBaseColumn = null, bool bInIsCountColumn = false) { name = SummaryTableColumn.getAggregateTypePrefix(inAggregateType) + inName; isNumeric = inIsNumeric; displayName = inDisplayName; isRowWeightColumn = inIsRowWeightColumn; elementType = inElementType; formatInfo = inFormatInfo; tooltip = inTooltip; columnColourModifier = inColumnColourModifier; aggregateType = inAggregateType; aggregateBaseColumn = inAggregateBaseColumn; isCountColumn = bInIsCountColumn; if (inAggregateType == ColumnAggregateType.Avg && aggregateBaseColumn == null) { aggregateBaseColumn = this; } } public static string getAggregateTypePrefix(ColumnAggregateType aggregateType) { switch (aggregateType) { case ColumnAggregateType.Avg: return "Avg "; case ColumnAggregateType.Min: return "Min "; case ColumnAggregateType.Max: return "Max "; default: return ""; } } public string getKey(bool bIncludeTypeQualifier = true) { return bIncludeTypeQualifier ? SummaryTable.GetElementTypeStatPrefix(this.elementType) + name.ToLower() : name.ToLower(); } public SummaryTableColumn Clone() { SummaryTableColumn newColumn = new SummaryTableColumn(name, isNumeric, displayName, isRowWeightColumn, elementType, formatInfo, tooltip, columnColourModifier); newColumn.doubleValues.AddRange(doubleValues); newColumn.stringValues.AddRange(stringValues); newColumn.colourThresholds.AddRange(colourThresholds); newColumn.toolTips.AddRange(toolTips); newColumn.colourModifiers = colourModifiers.ToDictionary(entry => entry.Key, entry => entry.Value); // Deep copy newColumn.diffRowFrequency = diffRowFrequency; return newColumn; } // In the case of CSV stats, returns the category. Otherwise, returns an empty string public string GetStatCategory() { if (elementType == SummaryTableElement.Type.CsvStatAverage) { int lastSlashIndex = name.LastIndexOf('/'); if (lastSlashIndex != -1) { return name.Substring(0, lastSlashIndex); } } return ""; } public bool IsDateFormat() { return formatInfo != null && formatInfo.IsDate(); } private double FilterInvalidValue(double value) { return value == double.MaxValue ? 0.0 : value; } public double GetMaxValue() { if (!isNumeric) { return double.MaxValue; } double maxValue = double.MinValue; int count = 0; foreach (double value in doubleValues) { if (value != double.MaxValue) { maxValue = Math.Max(value, maxValue); count++; } } if (count == 0) { return 0.0; } return maxValue; } public double GetMinValue() { if (!isNumeric) { return double.MaxValue; } double minValue = double.MaxValue; int count = 0; foreach (double value in doubleValues) { if (value != double.MaxValue) { minValue = Math.Min(value, minValue); count++; } } if (count == 0) { return 0.0; } return minValue; } public double GetAvgValue() { if (!isNumeric) { return double.MaxValue; } double total = 0.0; int count = 0; foreach (double value in doubleValues) { if (value != double.MaxValue) { total += value; count++; } } if (count == 0) { return 0.0; } return total / (double)count; } public void AddDiffRows(bool bIsFirstColumn, DiffRowFrequency inDiffRowFrequency, bool bShowOnlyDiffRows) { if (diffRowFrequency != DiffRowFrequency.None) { throw new Exception("Column already has diff rows!"); } // Add a diff row for every row after the first one int oldCount = GetCount(); diffRowFrequency = inDiffRowFrequency; bool bDiffRowsAlternating = diffRowFrequency == DiffRowFrequency.Alternating; int diffRowCount = bDiffRowsAlternating ? oldCount - 1 : oldCount / 2; int newCount = oldCount + diffRowCount; // Create new lists with counts reserved List newDoubleValues = new List(doubleValues.Count > 0 ? newCount : 0); List newStringValues = new List(stringValues.Count > 0 ? newCount : 0); List newToolTips = new List(toolTips.Count > 0 ? newCount : 0); List newColourThresholds = new List(colourThresholds.Count > 0 ? newCount : 0); bool bComputeDiff = isNumeric && !IsDateFormat() && !isCountColumn; static bool NeedsDiffColumn(int originalRowIndex, bool bShowIntermediateDiffRows) { return bShowIntermediateDiffRows ? originalRowIndex > 0 : originalRowIndex % 2 == 1; } // Add diff rows to each of the arrays for (int i = 0; i < doubleValues.Count; i++) { newDoubleValues.Add(doubleValues[i]); if (NeedsDiffColumn(i, bDiffRowsAlternating)) { if (bComputeDiff) { double thisValue = FilterInvalidValue(doubleValues[i]); double prevValue = FilterInvalidValue(doubleValues[i - 1]); newDoubleValues.Add(thisValue - prevValue); } else { double valueToShow = 0.0; if (bShowOnlyDiffRows && isCountColumn ) { // If we're showing only diff rows then display the count column if the values are equal double thisValue = FilterInvalidValue(doubleValues[i]); double prevValue = FilterInvalidValue(doubleValues[i - 1]); if (thisValue == prevValue) { valueToShow = prevValue; } } newDoubleValues.Add(valueToShow); } } } for (int i = 0; i < stringValues.Count; i++) { newStringValues.Add(stringValues[i]); if (NeedsDiffColumn(i, bDiffRowsAlternating)) { if (bShowOnlyDiffRows) { if (stringValues[i] == stringValues[i-1]) { newStringValues.Add(stringValues[i]); } else { newStringValues.Add(""); } } else { newStringValues.Add(bIsFirstColumn ? "Diff" : ""); } } } for (int i = 0; i < toolTips.Count; i++) { newToolTips.Add(toolTips[i]); if (NeedsDiffColumn(i, bDiffRowsAlternating)) { newToolTips.Add(""); } } for (int i = 0; i < colourThresholds.Count; i++) { newColourThresholds.Add(colourThresholds[i]); if (NeedsDiffColumn(i, bDiffRowsAlternating)) { newColourThresholds.Add(null); } } doubleValues = newDoubleValues; stringValues = newStringValues; toolTips = newToolTips; colourThresholds = newColourThresholds; } bool IsDiffRow(int rowIndex) { return SummaryTable.IsDiffRow(diffRowFrequency, rowIndex); } // Computes a score (significance indicator) for a column based on its diff values. This takes into account the max value. If LowIsBad for this column then the sign is reversed public double GetDiffScore() { if (diffRowFrequency == DiffRowFrequency.None || !isNumeric) { return 0.0; } bool bLowIsBad = formatInfo != null && formatInfo.autoColorizeMode == AutoColorizeMode.LowIsBad; // Find the max of all diff values for this column. If LowIsBad then we reverse the sign double maxDiffScore = double.MinValue; for (int rowIndex = 0; rowIndex < GetCount(); rowIndex++) { if (IsDiffRow(rowIndex)) { double diffValue = GetValue(rowIndex); maxDiffScore = Math.Max(maxDiffScore, bLowIsBad ? -diffValue : diffValue); } } return maxDiffScore; } // Computes the max of the abs diff values for a column public double GetMaxAbsDiff() { if (diffRowFrequency == DiffRowFrequency.None || !isNumeric) { return 0.0; } double maxAbsDiff = double.MinValue; for (int rowIndex = 0; rowIndex < GetCount(); rowIndex++) { if (IsDiffRow(rowIndex)) { maxAbsDiff = Math.Max(maxAbsDiff, Math.Abs(GetValue(rowIndex))); } } return maxAbsDiff; } public string GetDisplayName(string hideStatPrefix=null, bool bAddStatCategorySeparatorSpaces = true, bool bGreyOutStatCategories = false) { if (displayName != null) { return displayName; } // Trim the stat name suffix if necessary string statName = name; if (hideStatPrefix != null) { string baseStatName = SummaryTable.GetBaseStatNameWithPrefixAndSuffix(statName, out string prefix, out string suffix); if (baseStatName.ToLower().StartsWith(hideStatPrefix.ToLower() ) ) { statName = prefix + baseStatName.Substring(hideStatPrefix.Length) + suffix; } } if (bGreyOutStatCategories) { string baseStatName = SummaryTable.GetBaseStatNameWithPrefixAndSuffix(statName, out string prefix, out _); int idx = baseStatName.LastIndexOf("/"); if (idx >= 0) { statName = prefix + "" + baseStatName.Substring(0, idx+1) + "" + baseStatName.Substring(idx+1)+ ""; } } if (bAddStatCategorySeparatorSpaces) { // Add a space to enable line breaks between slashes to reduce column width. return statName.Replace("/", "/"); } return statName; } public void ReserveRows(int count) { if (isNumeric) { doubleValues.Capacity = count; colourThresholds.Capacity = count; } else { stringValues.Capacity = count; } } public void SetValue(int index, double value) { if (!isNumeric) { // This is already a non-numeric column. Better treat this as a string value SetStringValue(index, value.ToString()); return; } // Grow to fill if necessary if (index >= doubleValues.Count) { for (int i = doubleValues.Count; i <= index; i++) { doubleValues.Add(double.MaxValue); } } doubleValues[index] = value; } void convertToStrings() { if (isNumeric) { stringValues = new List(); foreach (float f in doubleValues) { stringValues.Add(f.ToString()); } doubleValues = new List(); isNumeric = false; } } public void SetColourThresholds(int index, ColourThresholdList value) { // Grow to fill if necessary if (index >= colourThresholds.Count) { for (int i = colourThresholds.Count; i <= index; i++) { colourThresholds.Add(null); } } colourThresholds[index] = value; } public ColourThresholdList GetColourThresholds(int index) { if (index < colourThresholds.Count) { return colourThresholds[index]; } return null; } public string GetBackgroundColor(int index) { ColourThresholdList thresholds = null; double value = GetValue(index); if (value == double.MaxValue || IsDiffRow(index)) { return null; } if (formatInfo.colourThresholdList != null) { thresholds = formatInfo.colourThresholdList; } else if (colourThresholdOverride != null) { thresholds = colourThresholdOverride; } else { if (index < colourThresholds.Count) { thresholds = colourThresholds[index]; } if (thresholds == null) { return null; } } Colour modifier = null; if (columnColourModifier != null) { // Column modifier takes precedence over the cell modifier modifier = columnColourModifier; } else if (colourModifiers.ContainsKey(index)) { modifier = colourModifiers[index]; } string colourString = thresholds.GetColourForValue(value); if (modifier != null) { var colour = new Colour(colourString.Replace("'", "")); colourString = (colour * modifier).ToHTMLString(); } return colourString; } public string GetTextColor(int index) { const double absoluteIgnoreThreshold = 0.025; if (!bShowOnlyDiffRows && isCountColumn) { // If we're showing only diff rows then don't display the count column's diff row as a diff for formatting return null; } if (IsDiffRow(index) && isNumeric && index < doubleValues.Count ) { // For simplicity, just negate the diff value if lowIsBad double diffValue = doubleValues[index]; if (formatInfo.autoColorizeMode == AutoColorizeMode.LowIsBad) { diffValue *= -1.0; } // Diff absolute value is insignificant: output faded color if (Math.Abs(diffValue) < absoluteIgnoreThreshold) { // Very close to zero: just output grey if (Math.Abs(diffValue) < 0.001f) { return "#A0A0A0"; } // Slight red/green return diffValue > 0.0 ? "#B8A0A0" : "#A0B8A0"; } double prevValue = FilterInvalidValue(doubleValues[index - 2]); double thisValue = FilterInvalidValue(doubleValues[index - 1]); double maxValue = Math.Max(thisValue, prevValue); double percentOfMax = 100.0 * diffValue / maxValue; string red = "#B00000"; string green = "#008000"; // More than half a percent of max: output full colours if (percentOfMax >= 0.5) { return red; } if (percentOfMax <= -0.5) { return green; } // Output faded red/green return diffValue > 0.0 ? "#B8A0A0" : "#A0B8A0"; } return null; } public void ComputeColorThresholds(TableColorizeMode tableColorizeMode) { if ( tableColorizeMode == TableColorizeMode.Budget ) { return; } if (tableColorizeMode == TableColorizeMode.Off) { // Set empty color thresholds. This clears existing thresholds from summaries colourThresholds = new List(); return; } AutoColorizeMode autoColorizeMode = formatInfo.autoColorizeMode; if (autoColorizeMode == AutoColorizeMode.Off || !isNumeric) { return; } // Set a single colour threshold list for the whole column colourThresholds = new List(); double maxValue = -double.MaxValue; double minValue = double.MaxValue; double totalValue = 0.0; double validCount = 0.0; for (int i = 0; i < doubleValues.Count; i++) { if (IsDiffRow(i)) { continue; } double val = doubleValues[i]; if (val != double.MaxValue) { maxValue = Math.Max(val, maxValue); minValue = Math.Min(val, minValue); totalValue += val; validCount += 1.0; } } if (minValue == maxValue || validCount == 0.0) { return; } double averageValue = totalValue / validCount; double range = maxValue - minValue; // Disable colorization where values are very similar // The range has to be outside 0.25% of the average and >0.01 to get colorized double colorizationRangeThreshold = Math.Max( Math.Abs(averageValue) * 0.0025, 0.01 ); if (range < colorizationRangeThreshold) { return; } // Adjust Min/Max value to ensure close values are not just binary red/green. If min/max is within 1% of the average or 0.02, adjust accordingly double minColorizationRangeExtent = Math.Max( Math.Abs(averageValue) * 0.01, 0.02); maxValue = Math.Max(maxValue, averageValue + minColorizationRangeExtent); minValue = Math.Min(minValue, averageValue - minColorizationRangeExtent); Colour green = Colour.Green; Colour yellow = Colour.Yellow; Colour red = Colour.Red; colourThresholdOverride = new ColourThresholdList(); colourThresholdOverride.Add(new ThresholdInfo(minValue, (autoColorizeMode == AutoColorizeMode.HighIsBad) ? green : red)); colourThresholdOverride.Add(new ThresholdInfo(averageValue, yellow)); colourThresholdOverride.Add(new ThresholdInfo(averageValue, yellow)); colourThresholdOverride.Add(new ThresholdInfo(maxValue, (autoColorizeMode == AutoColorizeMode.HighIsBad) ? red : green)); } public List GetHeaderAttributes() { var attributes = new List(); if (columnColourModifier != null) { attributes.Add($"style='background-color:{Colour.White * columnColourModifier};'"); } if (tooltip != null) { attributes.Add($"title='{tooltip}'"); } else { string tooltipStr = SummaryTable.GetBaseStatNameWithPrefixAndSuffix(name, out _, out _); tooltipStr += " (" + this.elementType.ToString() + ")"; attributes.Add($"title='{tooltipStr}'"); } return attributes; } public int GetCount() { return Math.Max(doubleValues.Count, stringValues.Count); } public double GetValue(int index) { if (index >= doubleValues.Count) { return double.MaxValue; } return doubleValues[index]; } public bool AreAllValuesOverThreshold(double threshold) { if (!isNumeric) { return true; } foreach(double value in doubleValues) { if ( value > threshold && value != double.MaxValue) { return true; } } return false; } public void SetStringValue(int index, string value) { if (isNumeric) { // Better convert this to a string column, since we're trying to add a string to it convertToStrings(); } // Grow to fill if necessary if (index >= stringValues.Count) { for (int i = stringValues.Count; i <= index; i++) { stringValues.Add(""); } } stringValues[index] = value; isNumeric = false; } public string GetStringValue(int index, bool roundNumericValues = false, string forceNumericFormat = null) { if (isNumeric) { if (index >= doubleValues.Count || doubleValues[index] == double.MaxValue) { return ""; } double val = doubleValues[index]; string prefix = ""; bool bIsDiffRow = IsDiffRow(index); if (!bShowOnlyDiffRows && isCountColumn) { // If we're showing only diff rows then don't display the count column's diff row as a diff for formatting bIsDiffRow=false; } if (bIsDiffRow) { if ( val == 0.0 ) { return ""; } if ( val > 0.0 ) { prefix = "+"; } } if (forceNumericFormat != null) { if (forceNumericFormat == "date" && val != double.MaxValue) { DateTimeOffset dateTimeOffset = DateTimeOffset.FromUnixTimeSeconds((long)val); TimeSpan timeZoneOffset = formatInfo.dateTimeZone.GetUtcOffset(dateTimeOffset); dateTimeOffset = dateTimeOffset.Add(timeZoneOffset); return dateTimeOffset.ToString(formatInfo.dateFormat); } return prefix + val.ToString(forceNumericFormat); } else if (roundNumericValues) { double absVal = Math.Abs(val); double frac = absVal - (double)Math.Truncate(absVal); if (absVal >= 250.0f || frac < 0.0001f) { return prefix+val.ToString("0"); } if (absVal >= 50.0f) { return prefix + val.ToString("0.0"); } if (absVal >= 0.1) { return prefix + val.ToString("0.00"); } if (bIsDiffRow) { // Filter out close to zero results in diff columns if (absVal < 0.000) { return ""; } return prefix + val.ToString("0.00"); } else { return val.ToString("0.000"); } } return prefix + val.ToString(); } else { if (index >= stringValues.Count) { return ""; } if (forceNumericFormat != null) { // We're forcing a numeric format on something that's technically a string, but since we were asked, we'll try to do it anyway // Note: this is not ideal, but it's useful for collated table columns, which might get converted to non-numeric during collation try { return Convert.ToDouble(stringValues[index], System.Globalization.CultureInfo.InvariantCulture).ToString(forceNumericFormat); } catch { } // Ignore. Just fall through... } return stringValues[index]; } } public void SetToolTipValue(int index, string value) { // Grow to fill if necessary if (index >= toolTips.Count) { for (int i = toolTips.Count; i <= index; i++) { toolTips.Add(""); } } toolTips[index] = value; } public string GetToolTipValue(int index) { if (index >= toolTips.Count) { return ""; } return toolTips[index]; } public void DebugMarkRowInvalid(int index, string reason) { colourModifiers.Add(index, new Colour(0.5f, 0.5f, 0.5f, 0.5f)); SetToolTipValue(index, $"Cell invalid: {reason}"); } public void DebugMarkAsFiltered(string filterName, string reason) { tooltip = $"Filtered out by: {filterName}: {reason}"; columnColourModifier = new Colour(0.5f, 0.5f, 0.5f, 0.5f); } }; class SummaryTable { class SummaryTableColumnLookup { public SummaryTableColumnLookup() { Clear(); } public void Clear() { for (int i = 0; i < (int)SummaryTableElement.Type.COUNT + 1; i++) { columnLookupByType[i] = new Dictionary(); } } public List GetSortedKeyList(SummaryTableElement.Type type = SummaryTableElement.Type.ANY) { List listOut = columnLookupByType[(int)type].Keys.ToList(); listOut.Sort(); return listOut; } public void Add(string key, SummaryTableColumn column) { // Is there a type qualifier prefix in the key? If so, determine the type from the qualifier and stip the prefix if (key.StartsWith("[")) { throw new Exception("Key can't start with a name qualifier prefix ([)"); } // Add to the specific type lookup columnLookupByType[(int)column.elementType][key] = column; // Try to add to the global lookup, but handle collisions with priority Dictionary columnLookupAny = columnLookupByType[(int)SummaryTableElement.Type.ANY]; if (columnLookupAny.TryGetValue(key, out SummaryTableColumn existingColumn)) { // Handle collisions. These are allowed. If we want to be immune from collisions in lookups, we can specify a type // Prioritize summary table metrics over CSV stats, since this matches existing behaviour if (GetElementTypePriority(column.elementType) > GetElementTypePriority(existingColumn.elementType)) { columnLookupAny[key] = column; } } else { columnLookupAny[key] = column; } } public bool ContainsKey(string key, SummaryTableElement.Type type = SummaryTableElement.Type.ANY) { return Get(key, type) != null; } public void Remove(string key, SummaryTableElement.Type type = SummaryTableElement.Type.ANY) { // Is there a type qualifier prefix in the key? If so, determine the type from the qualifier and stip the prefix if (type == SummaryTableElement.Type.ANY && key.StartsWith("[")) { type = SummaryTable.GetQualfiedStatType(key, out key); } if (type == SummaryTableElement.Type.ANY) { // If a type isn't specified, we have to remove from all lookup tables, since there may be entries with different types and the same key for (int i = 0; i < (int)SummaryTableElement.Type.COUNT + 1; i++) { columnLookupByType[i].Remove(key); } } else { columnLookupByType[(int)type].Remove(key); // Only remove from the global column if the type is the same as specified Dictionary columnLookupAny = columnLookupByType[(int)SummaryTableElement.Type.ANY]; if (columnLookupAny.TryGetValue(key, out SummaryTableColumn column)) { if (column.elementType == type) { columnLookupAny.Remove(key); } } } } public SummaryTableColumn Get(string key, SummaryTableElement.Type type = SummaryTableElement.Type.ANY) { // Is there a type qualifier prefix in the key? If so, determine the type from the qualifier and stip the prefix if (type == SummaryTableElement.Type.ANY && key.StartsWith("[")) { type = SummaryTable.GetQualfiedStatType(key, out key); } if (columnLookupByType[(int)type].TryGetValue(key, out SummaryTableColumn columnOut)) { return columnOut; } return null; } // Determines the priority in the event of collisions in the ANY lookup (used when no type is specified) public static int GetElementTypePriority(SummaryTableElement.Type type) { switch (type) { case SummaryTableElement.Type.CsvMetadata: return 2; case SummaryTableElement.Type.CsvStatAverage: return 1; case SummaryTableElement.Type.SummaryTableMetric: return 3; case SummaryTableElement.Type.ToolMetadata: return 4; case SummaryTableElement.Type.ExternalMetadata: return 5; default: return 6; } } Dictionary[] columnLookupByType = new Dictionary[(int)SummaryTableElement.Type.COUNT + 1]; }; public SummaryTable() { } public void SetColumnFormatInfo(SummaryTableColumnFormatInfoCollection collection) { foreach (SummaryTableColumn column in columns) { column.formatInfo = collection != null ? collection.GetFormatInfo(column.name) : SummaryTableColumnFormatInfoCollection.DefaultColumnInfo; } } string MakeRowSortKey(List sortByColumnList, int rowIndex) { StringBuilder sb = new StringBuilder(); foreach (SummaryTableColumn column in sortByColumnList) { sb.Append('{'); sb.Append(column.GetStringValue(rowIndex)); sb.Append('}'); } return sb.ToString(); } private SummaryTableColumn CollateColumn(SummaryTableColumn srcColumn, ColumnAggregateType aggregateType, List collatedRowSourceRowCounts, SummaryTableColumn aggregateBaseColumn = null) { return CollateColumn(srcColumn, aggregateType, collatedRowSourceRowCounts, aggregateBaseColumn, out int temp); } private SummaryTableColumn CollateColumn(SummaryTableColumn srcColumn, ColumnAggregateType aggregateType, List collatedRowSourceRowCounts, SummaryTableColumn aggregateBaseColumn, out int validMultiRowCollationsCount) { SummaryTableColumn collatedColumn = new SummaryTableColumn(srcColumn.name, srcColumn.isNumeric, null, false, srcColumn.elementType, srcColumn.formatInfo, srcColumn.tooltip, srcColumn.columnColourModifier, aggregateType, aggregateBaseColumn); collatedColumn.ReserveRows(collatedRowSourceRowCounts.Count); validMultiRowCollationsCount = 0; int srcRowIndexOffset = 0; for (int collatedRowIndex = 0; collatedRowIndex < collatedRowSourceRowCounts.Count; collatedRowIndex++) { int mergedRowCount = collatedRowSourceRowCounts[collatedRowIndex]; int mergedRowEndIndex = srcRowIndexOffset + mergedRowCount; if (srcColumn.isNumeric) { // Set color thresholds based on the source column for (int srcRowIndex=srcRowIndexOffset; srcRowIndex < mergedRowEndIndex; srcRowIndex++) { collatedColumn.SetColourThresholds(collatedRowIndex, srcColumn.GetColourThresholds(srcRowIndex)); } switch (aggregateType) { case ColumnAggregateType.Avg: { double total = 0.0; double totalWeight = 0.0; for (int srcRowIndex = srcRowIndexOffset; srcRowIndex < mergedRowEndIndex; srcRowIndex++) { double rowValue = srcColumn.GetValue(srcRowIndex); if (rowValue != double.MaxValue) { double rowWeight = (rowWeightings != null) ? rowWeightings[srcRowIndex] : 1.0; total += rowValue * rowWeight; totalWeight += rowWeight; } } if (totalWeight > 0.0) { collatedColumn.SetValue(collatedRowIndex, total / totalWeight); } } break; case ColumnAggregateType.Min: { double minValue = double.MaxValue; for (int srcRowIndex = srcRowIndexOffset; srcRowIndex < mergedRowEndIndex; srcRowIndex++) { double rowValue = srcColumn.GetValue(srcRowIndex); if (rowValue != double.MaxValue) { minValue = Math.Min(minValue, srcColumn.GetValue(srcRowIndex)); } } if (minValue != double.MaxValue) { collatedColumn.SetValue(collatedRowIndex, minValue); } } break; case ColumnAggregateType.Max: { double maxValue = double.MinValue; for (int srcRowIndex = srcRowIndexOffset; srcRowIndex < mergedRowEndIndex; srcRowIndex++) { double rowValue = srcColumn.GetValue(srcRowIndex); if (rowValue != double.MaxValue) { maxValue = Math.Max(maxValue, srcColumn.GetValue(srcRowIndex)); } } if (maxValue != double.MinValue) { collatedColumn.SetValue(collatedRowIndex, maxValue); } } break; } if (mergedRowCount > 1) { validMultiRowCollationsCount++; } } else { // Collate non-numeric values. Display if they're the same, otherwise show "(multiple)" string mergedValue = null; bool bStringsMatch = true; for (int srcRowIndex = srcRowIndexOffset; srcRowIndex < mergedRowEndIndex; srcRowIndex++) { string currentValue = srcColumn.GetStringValue(srcRowIndex); if (mergedValue == null) { mergedValue = currentValue; } else { if (currentValue != mergedValue) { // The strings differ so we can't merge them mergedValue = "(multiple)"; bStringsMatch = false; break; } } } // If the rows match and are not null or empty then count them. // Note that we do not count as valid merged rows unless Count>1 since otherwise a single count of 1 would cause a row to be visible if CollatedStringVisibility is Auto if (mergedValue != null && mergedValue != "" && bStringsMatch && mergedRowCount > 1) { validMultiRowCollationsCount++; } collatedColumn.SetStringValue(collatedRowIndex, mergedValue == null ? "" : mergedValue); } srcRowIndexOffset += mergedRowCount; } return collatedColumn; } public SummaryTable CollateSortedTable(List collateByList, bool addMinMaxColumns, StringCollationVisibility stringCollationVisibility, DateCollationVisibility dateCollationVisibility) { // Find the collateBy columns HashSet collateByColumns = new HashSet(); foreach (string collateBy in collateByList) { string key = collateBy.ToLower(); if (columnLookup.ContainsKey(key)) { collateByColumns.Add(columnLookup.Get(key)); } } if (collateByColumns.Count == 0) { throw new Exception("None of the metadata strings were found:" + string.Join(", ", collateByList)); } // Generate the filtered sortBy column list List sortByColumnList = new List(); foreach (SummaryTableColumn srcColumn in columns) { if (collateByColumns.Contains(srcColumn)) { // finalSortByList.Add(srcColumn.name.ToLower()); sortByColumnList.Add(srcColumn); // Early out if we've found all the columns if (sortByColumnList.Count == collateByColumns.Count) { break; } } } // Count the number of rows for each collated row List collatedRowSourceRowCounts = new List(); int sourceRowCount = 0; string currentRowSortKey = MakeRowSortKey(sortByColumnList, 0); for (int i = 0; i < rowCount; i++) { string nextSortKey = (i + 1 < rowCount) ? MakeRowSortKey(sortByColumnList, i + 1) : null; sourceRowCount++; // If this is the last row (nextSortKey=null) or if the sort key is different then write out the count if (nextSortKey != currentRowSortKey) { collatedRowSourceRowCounts.Add(sourceRowCount); currentRowSortKey = nextSortKey; sourceRowCount = 0; } } List newColumns = new List(); // Generate the collated sortby columns foreach (SummaryTableColumn srcColumn in sortByColumnList) { SummaryTableColumn newColumn = new SummaryTableColumn(srcColumn.name, false, srcColumn.displayName, false, srcColumn.elementType, srcColumn.formatInfo); newColumns.Add(newColumn); int srcRowIndex = 0; for (int i=0; i 0 || stringCollationVisibility == StringCollationVisibility.Show ) { newColumns.Add(stringDataColumn); } } } } SummaryTable newTable = new SummaryTable(); newTable.columns = newColumns; newTable.InitColumnLookup(); newTable.rowCount = collatedRowSourceRowCounts.Count; newTable.firstStatColumnIndex = nonCollatedStartIndex; newTable.isCollated = true; newTable.hasMinMaxColumns = addMinMaxColumns; return newTable; } // Finds a particular aggregate column corresponding to a specified column private SummaryTableColumn GetAggregateColumn(SummaryTableColumn inColumn, ColumnAggregateType aggregateType) { // Add the avg column and the corresponding min/max columns if (inColumn.aggregateType == ColumnAggregateType.None) { throw new Exception("Column isn't an aggregate column"); } // Aggregate columns keep a ref to the avg/base column so use that if appropriate if (aggregateType == ColumnAggregateType.Avg && inColumn.aggregateBaseColumn != null) { return inColumn.aggregateBaseColumn; } string prefix = GetBaseStatPrefix(inColumn.name); string lookupKey = ( SummaryTableColumn.getAggregateTypePrefix(aggregateType) + inColumn.name.Substring(prefix.Length) ).ToLower(); SummaryTableColumn outColumn = columnLookup.Get(lookupKey); if (outColumn == null) { throw new Exception("Aggregate column "+lookupKey+" not found!"); } return outColumn; } SummaryTableColumnLookup GetColumnLookup() { return columnLookup; } public static string GetElementTypeStatPrefix(SummaryTableElement.Type type) { switch (type) { case SummaryTableElement.Type.SummaryTableMetric: return "[metric]"; case SummaryTableElement.Type.CsvStatAverage: return "[csv]"; case SummaryTableElement.Type.CsvMetadata: return "[meta]"; case SummaryTableElement.Type.ToolMetadata: return "[toolmeta]"; case SummaryTableElement.Type.ExternalMetadata: return "[extmeta]"; default: return "[invalid]"; } } public static SummaryTableElement.Type GetQualfiedStatType(string fullNameWithQualifier) { if (fullNameWithQualifier.StartsWith("[")) { if (fullNameWithQualifier.StartsWith("[metric]")) { return SummaryTableElement.Type.SummaryTableMetric; } else if (fullNameWithQualifier.StartsWith("[csv]")) { return SummaryTableElement.Type.CsvStatAverage; } else if (fullNameWithQualifier.StartsWith("[meta]")) { return SummaryTableElement.Type.CsvMetadata; } else if (fullNameWithQualifier.StartsWith("[toolmeta]")) { return SummaryTableElement.Type.ToolMetadata; } else if (fullNameWithQualifier.StartsWith("[extmeta]")) { return SummaryTableElement.Type.ExternalMetadata; } } return SummaryTableElement.Type.COUNT; } public static SummaryTableElement.Type GetQualfiedStatType(string fullNameWithQualifier, out string statNameOut) { if (fullNameWithQualifier.StartsWith("[")) { int endIdx = fullNameWithQualifier.IndexOf("]"); if (endIdx == -1) { throw new Exception("Filter stat " + fullNameWithQualifier + " includes a start bracket but no end bracket"); } // Strip off the stat qualifier statNameOut = fullNameWithQualifier.Substring(endIdx+1); if (fullNameWithQualifier.StartsWith("[metric]")) { return SummaryTableElement.Type.SummaryTableMetric; } else if (fullNameWithQualifier.StartsWith("[csv]")) { return SummaryTableElement.Type.CsvStatAverage; } else if (fullNameWithQualifier.StartsWith("[meta]")) { return SummaryTableElement.Type.CsvMetadata; } else if (fullNameWithQualifier.StartsWith("[toolmeta]")) { return SummaryTableElement.Type.ToolMetadata; } else if (fullNameWithQualifier.StartsWith("[extmeta]")) { return SummaryTableElement.Type.ExternalMetadata; } } statNameOut = fullNameWithQualifier; return SummaryTableElement.Type.COUNT; } public SummaryTable SortAndFilter(List columnFilterList, List rowSortList, bool bReverseSort, string weightByColumnName, IEnumerable additionalFilters, bool bSortTrailingDigitsAsNumeric, TableColumnSortMode tableColumnSortMode) { SummaryTable newTable = SortRows(rowSortList, bReverseSort, bSortTrailingDigitsAsNumeric); // Generate a column lookup SummaryTableColumnLookup columnLookup = newTable.GetColumnLookup(); // Make a list of all unique keys for each element type, including ANY List[] allMetadataKeysLists = new List[(int)SummaryTableElement.Type.COUNT + 1]; for (int i = 0; i < allMetadataKeysLists.Length; i++) { allMetadataKeysLists[i] = columnLookup.GetSortedKeyList((SummaryTableElement.Type)i); } // Add columns from the column filter list in the order they appear List newColumnList = new List(); foreach (string filterStr in columnFilterList) { string filterStrLower = filterStr.Trim().ToLower(); // Check for a qualifier which specifies the stat type bool startWild = filterStrLower.StartsWith("*"); bool endWild = filterStrLower.EndsWith("*"); if (startWild || endWild) { // Use the qualified list for wildcard matching if this entry was qualified with a type SummaryTableElement.Type statType = GetQualfiedStatType(filterStrLower, out string unqualifiedKey); List allKeysList = allMetadataKeysLists[(int)statType]; List keyList = CsvStats.WildcardMatchStringList(allKeysList, unqualifiedKey, false, true); // Resolve the keyList and output the columns List foundColumns = new List(); foreach (string key in keyList) { SummaryTableColumn column = columnLookup.Get(key, statType); if (column != null) { foundColumns.Add(column); } } // Apply sorting to wildcard columns if requested if (tableColumnSortMode == TableColumnSortMode.WildcardSortByMax) { foundColumns.Sort((a, b) => b.GetMaxValue().CompareTo(a.GetMaxValue())); } else if (tableColumnSortMode == TableColumnSortMode.WildcardSortByAvg) { foundColumns.Sort((a, b) => b.GetAvgValue().CompareTo(a.GetAvgValue())); } foreach (SummaryTableColumn column in foundColumns) { newColumnList.Add(column); } } else { SummaryTableColumn column = columnLookup.Get(filterStrLower); if (column != null) { newColumnList.Add(column); } } } // Remove duplicates newColumnList = newColumnList.Distinct().ToList(); // Apply sorting to all columns if requested if (tableColumnSortMode == TableColumnSortMode.SortByMax) { newColumnList.Sort((a, b) => b.GetMaxValue().CompareTo(a.GetMaxValue())); } else if (tableColumnSortMode == TableColumnSortMode.SortByAvg) { newColumnList.Sort((a, b) => b.GetAvgValue().CompareTo(a.GetAvgValue())); } // Compute row weights if (weightByColumnName != null && columnLookup.ContainsKey(weightByColumnName)) { SummaryTableColumn rowWeightColumn = columnLookup.Get(weightByColumnName); newTable.rowWeightings = new List(rowWeightColumn.GetCount()); for (int i = 0; i < rowWeightColumn.GetCount(); i++) { newTable.rowWeightings.Add(rowWeightColumn.GetValue(i)); } } // Run the additional filters on the columns if (additionalFilters != null) { var filteredColumns = new HashSet(); foreach (ISummaryTableColumnFilter filter in additionalFilters) { newColumnList = newColumnList.Where(column => !filter.ShouldFilter(column, this)).ToList(); } } newTable.columns = newColumnList; newTable.rowCount = rowCount; newTable.InitColumnLookup(); return newTable; } public void AddDiffRows(bool bSortColumnsByDiff, double columnDiffDisplayThreshold, bool bInShowOnlyDiffRows, bool bDiffRowsAlternating) { diffRowFrequency = bDiffRowsAlternating ? DiffRowFrequency.Alternating : DiffRowFrequency.AfterEachPair; bShowOnlyDiffRows = bInShowOnlyDiffRows; for (int i=0; i 0.0 ) { FilterColumnsByDiffThreshold(columnDiffDisplayThreshold); } if ( bSortColumnsByDiff ) { SortColumnsByDiffRows(); } // Just set rowWeightings to null for now. We shouldn't need it, since we should have already collated by this point rowWeightings = null; } public void ApplyDisplayNameMapping(Dictionary statDisplaynameMapping) { // Convert to a display-friendly name foreach (SummaryTableColumn column in columns) { if (statDisplaynameMapping != null && column.displayName == null) { string name = column.name; string statName = GetBaseStatNameWithPrefixAndSuffix(name, out string prefix, out string suffix); if (statDisplaynameMapping.ContainsKey(statName.ToLower())) { column.displayName = prefix + statDisplaynameMapping[statName.ToLower()] + suffix; } } } } public static string GetBaseStatPrefix(string inName) { GetBaseStatNameWithPrefixAndSuffix(inName, out string prefix, out _); return prefix; } public static string GetBaseStatName(string inName) { return GetBaseStatNameWithPrefixAndSuffix(inName, out _, out _); } public static string GetBaseStatNameWithPrefixAndSuffix(string inName, out string prefix, out string suffix) { suffix = ""; prefix = ""; string statName = inName; if (inName.StartsWith("Avg ") || inName.StartsWith("Max ") || inName.StartsWith("Min ")) { prefix = inName.Substring(0, 4); statName = inName.Substring(4); } if (statName.EndsWith(" Avg") || statName.EndsWith(" Max") || statName.EndsWith(" Min")) { suffix = statName.Substring(statName.Length - 4); statName = statName.Substring(0, statName.Length - 4); } return statName; } public void WriteToCSV(string csvFilename) { System.IO.StreamWriter csvFile = new System.IO.StreamWriter(csvFilename, false); List headerRow = new List(); foreach (SummaryTableColumn column in columns) { headerRow.Add(column.name); } csvFile.WriteLine(string.Join(",", headerRow)); for (int i = 0; i < rowCount; i++) { List rowStrings = new List(); foreach (SummaryTableColumn column in columns) { string cell = column.GetStringValue(i, false); // Sanitize so it opens in a spreadsheet (e.g. for buildversion) cell = cell.TrimStart('+'); rowStrings.Add(cell); } csvFile.WriteLine(string.Join(",", rowStrings)); } csvFile.Close(); } // Sorts columns by their diff score. // Requires max rows. Also works with collated tables with min/max private void SortColumnsByDiffRows() { List> numericColumnSortKeyPairs = new List>(); List staticColumns = new List(); foreach (SummaryTableColumn column in columns) { if (column.isNumeric && !column.isCountColumn ) { double maxDiffScore = column.GetDiffScore(); if (hasMinMaxColumns) { // If we have a collated table with avg/min/max then sort by the avg column and we'll re-add the min/max columns later // Columns without a prefix will be treated as static and ordered first if (column.aggregateType == ColumnAggregateType.Avg) { numericColumnSortKeyPairs.Add(new Tuple(column, maxDiffScore)); } else if (column.aggregateType == ColumnAggregateType.None) { staticColumns.Add(column); } } else { numericColumnSortKeyPairs.Add(new Tuple(column, maxDiffScore)); } } else { staticColumns.Add(column); } } columns = new List(); columns.AddRange(staticColumns); // Sort the numeric columns by stat numericColumnSortKeyPairs.Sort((a, b) => -a.Item2.CompareTo(b.Item2)); List sortedNumericColumns = new List(); foreach (Tuple pair in numericColumnSortKeyPairs) { sortedNumericColumns.Add(pair.Item1); } // Stable sort the columns by stat prefix IEnumerable sortedNumericColumnsOrderedByCategory = sortedNumericColumns.OrderBy(column => column.GetStatCategory()); foreach (SummaryTableColumn column in sortedNumericColumnsOrderedByCategory) { columns.Add(column); if (hasMinMaxColumns) { columns.Add(GetAggregateColumn(column, ColumnAggregateType.Min)); columns.Add(GetAggregateColumn(column, ColumnAggregateType.Max)); } } } // Filters out columns with a a max abs diff value below thes specified threshold // Requires max rows. Also works with collated tables with min/max private void FilterColumnsByDiffThreshold(double threshold) { List newColumns = new List(); foreach (SummaryTableColumn column in columns) { if (column.isNumeric && !column.isCountColumn) { // If this is a min or max column then we use the Avg column to compute the diff score. All 3 columns will be filtered together double maxAbsDiff = ( hasMinMaxColumns && column.aggregateBaseColumn != null ) ? column.aggregateBaseColumn.GetMaxAbsDiff() : column.GetMaxAbsDiff(); if (maxAbsDiff >= threshold) { newColumns.Add(column); } } else { newColumns.Add(column); } } columns = newColumns; } public void WriteToHTML( string htmlFilename, string VersionString, bool bSpreadsheetFriendlyStrings, List sectionBoundaries, bool bScrollableTable, TableColorizeMode tableColorizeMode, bool bAddMinMaxColumns, string hideStatPrefix, int maxColumnStringLength, string weightByColumnName, string title, bool bTranspose, bool showFilteredColumnDebug = false, SummaryTableStyle summaryTableStyle = null) { summaryTableStyle = summaryTableStyle ?? new SummaryTableStyle(); System.IO.StreamWriter htmlFile = new System.IO.StreamWriter(htmlFilename, false); string cellPadding = "2px 4px 2px 4px"; if (isCollated) { cellPadding = "4px 4px 4px 4px"; } // Generate an automatic title if (title==null) { title = htmlFilename.Replace("_Email.html", "").Replace(".html", "").Replace("\\", "/"); title = title.Substring(title.LastIndexOf('/') + 1); } htmlFile.WriteLine(""); htmlFile.WriteLine("Perf Summary: "+ title + ""); bool bAddStatNameSpacing = !bTranspose; bool bGreyOutStatPrefixes = bScrollableTable && bTranspose; // Figure out the sticky column count int stickyColumnCount = 0; if (bScrollableTable) { stickyColumnCount = 1; if (isCollated && !bTranspose) { for (int i = 0; i < columns.Count; i++) { if (columns[i].isCountColumn) { stickyColumnCount = i + 1; break; } } } } // Automatically colorize the table if requested. // We run this even if colorize is off as we need to overwrite the values. if (tableColorizeMode == TableColorizeMode.Auto || tableColorizeMode == TableColorizeMode.Off) { foreach (SummaryTableColumn column in columns) { column.ComputeColorThresholds(tableColorizeMode); } } if (bScrollableTable) { // Insert some javascript to make the columns sticky. It's not possible to do this for multiple columns with pure CSS, since you need to compute the X offset dynamically // We need to do this when the page is loaded or the window is resized htmlFile.WriteLine(""); } htmlFile.WriteLine(""); htmlFile.WriteLine(""); HtmlTable htmlTable = new HtmlTable("id='mainTable'", 1, columns.Count); // Make a header row, but don't add it to the table yet HtmlTable.Row headerRow = new HtmlTable.Row(); // Write the header if (isCollated) { HtmlTable.Row topHeaderRow = headerRow; // Add the special columns (up to Count) to the lower header row for (int i = 0; i < firstStatColumnIndex; i++) { headerRow.AddCell(columns[i].GetDisplayName(hideStatPrefix, bAddStatNameSpacing, bGreyOutStatPrefixes), String.Join(" ", columns[i].GetHeaderAttributes())); } if (bAddMinMaxColumns) { topHeaderRow = htmlTable.CreateRow(); htmlTable.numHeaderRows = 2; if (bScrollableTable) { topHeaderRow.AddCell("

" + title + "

", "colspan='" + firstStatColumnIndex + "'"); } else { topHeaderRow.AddCell("", "colspan='" + firstStatColumnIndex + "'"); } // Add the stat columns int aggregateColumnCount = 0; for (int i = firstStatColumnIndex; i < columns.Count; i++) { SummaryTableColumn column = columns[i]; string statName = GetBaseStatNameWithPrefixAndSuffix(column.GetDisplayName(hideStatPrefix, bAddStatNameSpacing, bGreyOutStatPrefixes), out string prefix, out string suffix); string cellText = prefix.Trim(); if (column.aggregateType == ColumnAggregateType.None) { cellText = "-"; } else if (column.IsDateFormat()) { cellText = column.aggregateType == ColumnAggregateType.Max ? "newest" : "oldest"; } headerRow.AddCell(cellText); aggregateColumnCount++; // Update the top row if this is the last column of an aggregate (ie max) if (column.aggregateType == ColumnAggregateType.None || column.aggregateBaseColumn == null || column.aggregateType == ColumnAggregateType.Max) { string attributes = ""; if (aggregateColumnCount > 1) { attributes = $"colspan='{aggregateColumnCount}' "; } attributes += String.Join(" ", column.GetHeaderAttributes()); topHeaderRow.AddCell(statName + suffix, attributes); aggregateColumnCount = 0; } } } else { // Add the stat columns for (int i = firstStatColumnIndex; i < columns.Count; i++) { string statName = GetBaseStatNameWithPrefixAndSuffix(columns[i].GetDisplayName(hideStatPrefix, bAddStatNameSpacing, bGreyOutStatPrefixes), out _, out string suffix); headerRow.AddCell(statName + suffix, String.Join(" ", columns[i].GetHeaderAttributes())); } } } else { foreach (SummaryTableColumn column in columns) { headerRow.AddCell(column.GetDisplayName(hideStatPrefix, bAddStatNameSpacing, bGreyOutStatPrefixes), String.Join(" ", column.GetHeaderAttributes())); } } htmlTable.AddRow(headerRow); // Work out which rows are major/minor section boundaries Dictionary rowSectionBoundaryLevel = new Dictionary(); if (sectionBoundaries != null && !bTranspose) { foreach (SummarySectionBoundaryInfo sectionBoundaryInfo in sectionBoundaries) { // Skip this section boundary info if it's not in this table type if (isCollated && !sectionBoundaryInfo.inCollatedTable) { continue; } if (!isCollated && !sectionBoundaryInfo.inFullTable) { continue; } string prevSectionName = ""; for (int i = 0; i < rowCount; i++) { int boundaryLevel = 0; if (sectionBoundaryInfo != null) { // Work out the section name if we have section boundary info. When it changes, apply the sectionStart CSS class string sectionName = ""; if (sectionBoundaryInfo != null && columnLookup.ContainsKey(sectionBoundaryInfo.statName)) { // Get the section name if (!columnLookup.ContainsKey(sectionBoundaryInfo.statName)) { continue; } SummaryTableColumn col = columnLookup.Get(sectionBoundaryInfo.statName); sectionName = col.GetStringValue(i); // if we have a start token then strip before it if (sectionBoundaryInfo.startToken != null) { int startTokenIndex = sectionName.IndexOf(sectionBoundaryInfo.startToken); if (startTokenIndex != -1) { sectionName = sectionName.Substring(startTokenIndex + sectionBoundaryInfo.startToken.Length); } } // if we have an end token then strip after it if (sectionBoundaryInfo.endToken != null) { int endTokenIndex = sectionName.IndexOf(sectionBoundaryInfo.endToken); if (endTokenIndex != -1) { sectionName = sectionName.Substring(0, endTokenIndex); } } } if (sectionName != prevSectionName && i > 0) { // Update the row's boundary type info boundaryLevel = sectionBoundaryInfo.level; if (rowSectionBoundaryLevel.ContainsKey(i)) { // Lower level values override higher ones boundaryLevel = Math.Min(rowSectionBoundaryLevel[i], boundaryLevel); } rowSectionBoundaryLevel[i] = boundaryLevel; } prevSectionName = sectionName; } } } } // Add the rows to the table for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { if (!IsRowVisible(rowIndex)) { continue; } string rowClassStr = ""; // Is this a major/minor section boundary if (rowSectionBoundaryLevel.ContainsKey(rowIndex)) { int sectionLevel = rowSectionBoundaryLevel[rowIndex]; if (sectionLevel < 3) { rowClassStr = " class='sectionStartLevel" + sectionLevel + "'"; } } HtmlTable.Row currentRow = htmlTable.CreateRow(rowClassStr); int columnIndex = 0; foreach (SummaryTableColumn column in columns) { List attributes = new List(); // Add the tooltip for non-collated tables if (!isCollated) { string toolTip = column.GetToolTipValue(rowIndex); if (toolTip == "") { if (column.isNumeric && column.formatInfo != null && column.formatInfo.IsDate()) { // For dates use a standard UTC timestamp for the tooltip double val = column.GetValue(rowIndex); if (val < double.MaxValue ) { DateTimeOffset dateTimeOffset = DateTimeOffset.FromUnixTimeSeconds((long)val); toolTip = dateTimeOffset.ToString("yyyy-MM-dd HH:mm:ss (UTC)"); } } else { toolTip = column.GetDisplayName(); } } attributes.Add("title='" + toolTip + "'"); } string bgColour = column.GetBackgroundColor(rowIndex); if (bgColour != null) { attributes.Add("bgcolor=" + bgColour); } Dictionary styleAttributes = new Dictionary(); string textColour = column.GetTextColor(rowIndex); if (textColour != null) { styleAttributes.Add("color", textColour); } if (column.formatInfo != null && column.formatInfo.noWrap) { // Disable whitespace wrapping so the column is sized to the width of the longest cell item. styleAttributes.Add("white-space", "nowrap"); } bool bold = false; SummaryTableColumnFormatInfo columnFormat = column.formatInfo; int maxStringLength = Math.Min( isCollated ? columnFormat.maxStringLengthCollated : columnFormat.maxStringLength, maxColumnStringLength); string numericFormat = columnFormat.numericFormat; string stringValue = column.GetStringValue(rowIndex, true, numericFormat); // Check if we have any value buckets, if so lookup the bucket name for the value and display that with the value. if (column.isNumeric && stringValue.Length > 0 && columnFormat.bucketNames.Count > 0 && columnFormat.bucketThresholds.Count > 0) { double value = column.GetValue(rowIndex); int bucketIndex = 0; for (bucketIndex = 0; bucketIndex < columnFormat.bucketThresholds.Count; ++bucketIndex) { if (value <= columnFormat.bucketThresholds[bucketIndex]) { break; } } bucketIndex = Math.Min(bucketIndex, columnFormat.bucketNames.Count-1); if (columnFormat.includeValueWithBucketName) { stringValue = columnFormat.bucketNames[bucketIndex] + " (" + stringValue + ")"; } else { stringValue = columnFormat.bucketNames[bucketIndex]; } } if (stringValue.Length > maxStringLength) { stringValue = TableUtil.SafeTruncateHtmlTableValue(stringValue, maxStringLength); } if (bSpreadsheetFriendlyStrings && !column.isNumeric) { stringValue = "'" + stringValue; } currentRow.AddCell( (bold ? "" : "") + stringValue + (bold ? "" : ""), attributes, styleAttributes); columnIndex++; } } if (bTranspose) { htmlTable = htmlTable.Transpose(); htmlTable.Set(0, 0, new HtmlTable.Cell(title, "")); // Add a section boundary where the stats start htmlTable.rows[htmlTable.numHeaderRows + firstStatColumnIndex - 1].attributes = " class='sectionStartLevel2'"; } // Apply final formatting htmlTable.rows[htmlTable.numHeaderRows-1].attributes += " class='lastHeaderRow'"; if (bScrollableTable) { // Apply stripe colors to the sticky columns to make them opaque (these need to render on top). Can't use per-cell CSS because we don't want to override existing cell colors string[] stripeColors = { "bgcolor='"+oddColor+"'", "bgcolor='"+evenColor+"'" }; for (int rowIndex = htmlTable.numHeaderRows; rowIndex < htmlTable.rows.Count; rowIndex++) { HtmlTable.Row row = htmlTable.rows[rowIndex]; for (int colIndex = 0; colIndexCreated with PerfReportTool " + VersionString + extraString + "

"); htmlFile.WriteLine(""); htmlFile.Close(); } public SummaryTable SortRows(List rowSortList, bool reverseSort, bool sortTrailingDigitsAsNumeric = false) { List> columnRemapping = new List>(); for (int i = 0; i < rowCount; i++) { string key = ""; foreach (string s in rowSortList) { SummaryTableColumn column = columnLookup.Get(s.ToLower()); if (column != null) { string columnKey = column.GetStringValue(i, false, "0000000000.0000000000"); if (sortTrailingDigitsAsNumeric && !column.isNumeric) { // If there's an integer suffix in the column value, pad it with zeroes for sorting purposes int integerSuffixStartIndex = -1; for (int ci = columnKey.Length-1; ci>0; ci-- ) { char c = columnKey[ci]; if (c < '0' || c > '9') { break; } integerSuffixStartIndex = ci; } if (integerSuffixStartIndex >= 0) { string integerSuffixPadded = columnKey.Substring(integerSuffixStartIndex).PadLeft(12,'0'); columnKey = columnKey.Substring(0, integerSuffixStartIndex) + integerSuffixPadded; } } key += "{" + columnKey + "}"; } else { key += "{}"; } } columnRemapping.Add(new KeyValuePair(key, i)); } columnRemapping.Sort(delegate (KeyValuePair m1, KeyValuePair m2) { return m1.Key.CompareTo(m2.Key); }); // Reorder the columns List newColumns = new List(); foreach (SummaryTableColumn srcCol in columns) { SummaryTableColumn destCol = new SummaryTableColumn(srcCol.name, srcCol.isNumeric, null, false, srcCol.elementType, srcCol.formatInfo); for (int i = 0; i < rowCount; i++) { int srcIndex = columnRemapping[i].Value; int destIndex = reverseSort ? rowCount - 1 - i : i; if (srcCol.isNumeric) { destCol.SetValue(destIndex, srcCol.GetValue(srcIndex)); } else { destCol.SetStringValue(destIndex, srcCol.GetStringValue(srcIndex)); } destCol.SetColourThresholds(destIndex, srcCol.GetColourThresholds(srcIndex)); destCol.SetToolTipValue(destIndex, srcCol.GetToolTipValue(srcIndex)); } newColumns.Add(destCol); } SummaryTable newTable = new SummaryTable(); newTable.columns = newColumns; newTable.rowCount = rowCount; newTable.firstStatColumnIndex = firstStatColumnIndex; newTable.isCollated = isCollated; newTable.InitColumnLookup(); return newTable; } void InitColumnLookup() { columnLookup.Clear(); foreach (SummaryTableColumn col in columns) { columnLookup.Add(col.name.ToLower(), col); } } public void AddRowData(SummaryTableRowData metadata, bool bIncludeCsvStatAverages, bool bIncludeHiddenStats) { foreach (string key in metadata.dict.Keys) { SummaryTableElement value = metadata.dict[key]; if (value.type == SummaryTableElement.Type.CsvStatAverage && !bIncludeCsvStatAverages) { continue; } if (value.GetFlag(SummaryTableElement.Flags.Hidden) && !bIncludeHiddenStats) { continue; } SummaryTableColumn column = null; if (!columnLookup.ContainsKey(key)) { column = new SummaryTableColumn(value.name, value.isNumeric, null, false, value.type); columnLookup.Add(key, column); columns.Add(column); } else { column = columnLookup.Get(key); } if (value.isNumeric) { column.SetValue(rowCount, (double)value.numericValue); } else { column.SetStringValue(rowCount, value.value); } column.SetColourThresholds(rowCount, value.colorThresholdList); column.SetToolTipValue(rowCount, value.tooltip); } rowCount++; } public int Count { get { return rowCount; } } public SummaryTableColumn GetColumnByName(string name) { return columnLookup.Get(name); } public bool IsRowVisible(int rowIndex) { return SummaryTable.IsRowVisible(diffRowFrequency, bShowOnlyDiffRows, rowIndex); } public static bool IsRowVisible(DiffRowFrequency diffRowFrequency, bool bShowOnlyDiffRows, int rowIndex) { if (bShowOnlyDiffRows) { return IsDiffRow(diffRowFrequency, rowIndex); } else { return true; } } public static bool IsDiffRow(DiffRowFrequency diffRowFrequency, int rowIndex) { if (rowIndex < 2) { return false; } switch (diffRowFrequency) { case DiffRowFrequency.Alternating: return ((rowIndex - 2) % 2) == 0; case DiffRowFrequency.AfterEachPair: return ((rowIndex - 2) % 3) == 0; case DiffRowFrequency.None: default: return false; } } SummaryTableColumnLookup columnLookup = new SummaryTableColumnLookup(); List columns = new List(); List rowWeightings = null; int rowCount = 0; int firstStatColumnIndex = 0; bool isCollated = false; bool hasMinMaxColumns = false; DiffRowFrequency diffRowFrequency = DiffRowFrequency.None; bool bShowOnlyDiffRows = false; }; class HtmlTable { public class Row { public Row(string inAttributes="", int reserveColumnCount=0) { attributes = inAttributes; if (reserveColumnCount > 0) { cells = new List(reserveColumnCount); } else { cells = new List(); } } public void AddCell(string contents="", string attributes="") { cells.Add(new Cell(contents, attributes)); } public void AddCell(string contents, List attributes, Dictionary styleAttributes) { List styleLines = styleAttributes.Select(pair => $"{pair.Key}:{pair.Value}").ToList(); attributes.Add($"style='{String.Join(";", styleLines)}'"); cells.Add(new Cell(contents, String.Join(" ",attributes))); } public void WriteToHtml(System.IO.StreamWriter htmlFile, bool bIsHeaderRow ) { string attributesStr = attributes.Length == 0 ? "" : " " + attributes; htmlFile.Write(""); foreach (Cell cell in cells) { if (cell == null) { htmlFile.Write(""); } else { cell.WriteToHtml(htmlFile, bIsHeaderRow); } } htmlFile.WriteLine(""); } public void Set(int ColIndex, Cell cell) { if (cells.Count < ColIndex + 1) { cells.Capacity = ColIndex + 1; while (cells.Count < ColIndex + 1) { cells.Add(null); } } cells[ColIndex] = cell; } public string attributes; public List cells; } public class Cell { public Cell() { } public Cell(Cell srcCell) { contents = srcCell.contents; attributes = srcCell.attributes; } public Cell(string inContents, string inAttributes, bool bInIsHeader=false) { contents = inContents; attributes = inAttributes; } public void WriteToHtml(System.IO.StreamWriter htmlFile, bool bIsHeaderRow) { string AttributesStr = attributes.Length == 0 ? "" : " "+ attributes; string CellType = bIsHeaderRow ? "th" : "td"; htmlFile.Write("<" + CellType + AttributesStr + ">" + contents + ""); } public string contents; public string attributes; } public HtmlTable(string inAttributes="", int inNumHeaderRows=1, int reserveColumnCount=0) { rows = new List(); attributes = inAttributes; numHeaderRows = inNumHeaderRows; reserveColumnCount = 0; } public HtmlTable Transpose() { // NOTE: Row attributes are stripped when transposing! HtmlTable transposedTable = new HtmlTable(attributes, 1, rows.Count); for (int i=0; i"); // Compute the number of columns, just in case not all rows are equal for (int i=0; i"); } public void Set(int RowIndex, int ColIndex, Cell cell) { // Make sure we have enough rows if (rows.Count < RowIndex + 1) { rows.Capacity = RowIndex + 1; while (rows.Count < RowIndex + 1) { rows.Add(new Row("", reserveColumnCount)); } } // Make sure the row is big enough rows[RowIndex].Set(ColIndex, cell); } public Row CreateRow(string attributes="") { Row newRow = new Row(attributes, reserveColumnCount); rows.Add(newRow); return newRow; } public void AddRow(Row row) { rows.Add(row); } public List rows; public string attributes; public int numHeaderRows = 0; int reserveColumnCount = 0; }; }