dbMango/Rms.Risk.Mango.Pivot.UI/Pivot/ReadOnlyPivotTable.razor
Alexander Shabarshov 2a7a24c9e7 Initial contribution
2025-11-03 14:43:26 +00:00

442 lines
15 KiB
Plaintext

@using System.Drawing
@using System.Dynamic
@using ChartJs.Blazor
@using ChartJs.Blazor.LineChart
@using Rms.Risk.Mango.Pivot.Core
@inject IJSRuntime Js
@inject NavigationManager NavigationManager
@inject IPivotSharingService PivotSharingService
@*
* dbMango
*
* Copyright 2025 Deutsche Bank AG
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*@
<div class="@Class">
<style>
.chart-canvas {
width: 1024px;
height: 470px;
margin-bottom: 50px;
}
.glow-pos {
background: #279320;
}
.glow-neg {
background: #723e36;
}
.glow-last {
border-bottom-color: yellow;
border-bottom-style: double;
}
</style>
<div class="@ChartClass">
<div class="chart-canvas ml-3">
<Chart Config="@ChartConfig"/>
</div>
</div>
@if (_pivotRows is { Length: > 0 }
&& (ShowIfEmpty || (!PivotData.Get(0, 0)?.ToString()?.Equals("No results") ?? false)))
{
@if (!string.IsNullOrEmpty(Title))
{
<h2>@Title</h2>
}
<TableControl Class="table table-hover table-striped table-forge fit-content"
Items="@_pivotRows"
PageSize="@Rows"
@bind-FilteredItems="FilteredRows"
CellStyle="GetCellStyle"
GetCellClassCallback="GetCellClassCallback"
Totals="@VisibleTotals"
@bind-SortMode="SortMode"
@bind-CurrentSortColumn="SortColumn"
>
@foreach (var field in _pivotRows[0].GetDynamicMemberNames())
{
var headerStyle = HeaderStyles.GetValueOrDefault(field, "");
<TableColumnControl Name="@field" Field="@field" Format="@_pivotRows[0].GetFormat(field)" ShowTotals="@_pivotRows[0].ShouldShowTotals(field)" Class="@ColumnClass" HeaderStyle="@headerStyle">
<Template>
<span @onclick="@(() => OnCellClick((DynamicObject)context.Row, field))">
@TableControl.ConvertToString(TableControl.GetLambdaForValue(field)(context.Row), _pivotRows[0].GetFormat(field))
</span>
</Template>
</TableColumnControl>
}
</TableControl>
}
</div>
@code{
[CascadingParameter] public IModalService Modal { get; set; } = null!;
[Parameter] public string Class { get; set; } = "";
[Parameter, EditorRequired]
public IPivotedData PivotData
{
get;
set
{
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (field == value || value == null)
return;
field = value;
_totals.Clear();
_shadowHeaders = field.GetColumnPositions();
_descriptorsCache.Clear();
_fieldDescriptorsCache.Clear();
_pivotRows = Enumerable
.Range(0, field.Count)
.Select(x => new PivotRow(field, x, _shadowHeaders, GetColumnDescriptorInternal, GetFieldDescriptorInternal))
.ToArray()
;
_totals.Update(field);
if (PivotDef != null)
{
try
{
ChartHelper.UpdateLineChart(PivotDef, field, x => _pivotRows[0].GetFormat(x));
}
catch (Exception)
{
// ignore
}
}
InvokeAsync(StateHasChanged);
}
} = new ArrayBasedPivotData([]);
[Parameter] public PivotDefinition? PivotDef { get; set; }
[Parameter] public GroupedCollection? SelectedCollectionNode { get; set; }
[Parameter] public int Rows { get; set; } = 35;
[Parameter] public Func<string /*columnName*/, PivotColumnDescriptor?> GetColumnDescriptor { get; set; } = _ => null;
[Parameter] public Func<string /*columnName*/, PivotFieldDescriptor?> GetFieldDescriptor { get; set; } = _ => null;
[Parameter] public Func<DynamicObject, string, Task<bool>> HandleCellClick { get; set; } = (_, _) => Task.FromResult(false);
[Parameter] public string Title { get; set; } = "";
[Parameter] public bool ShowIfEmpty { get; set; } = true;
[Parameter] public Dictionary<string, string> HeaderStyles { get; set; } = [];
[Parameter]
public bool IsExportEnabled
{
get => _pivotRows is { Length: > 0 };
// ReSharper disable once ValueParameterNotUsed
set
{
// ignore
}
}
[Parameter] public EventCallback<bool> IsExportEnabledChanged { get; set; }
public Task CopyCsv() => CopyCsvInternal();
public Task ExportCsv(string destFileName) => ExportCsvInternal(destFileName);
private PivotRow[] _pivotRows = [];
private readonly MinMaxCache _totals = new();
private PivotColumnDescriptor[] ? _descriptors;
private Dictionary<string, PivotFieldDescriptor> ? _fieldTypes;
private List<dynamic> _filteredRows = [];
private readonly DelayedExecution _delayedUpdate = new(TimeSpan.FromMilliseconds(1500));
private Dictionary<string, int> _shadowHeaders = [];
private bool ShowTotals => PivotDef?.ShowTotals ?? false;
private IMinMaxCache? VisibleTotals => ShowTotals ? _totals : null;
private LineConfig ChartConfig => ChartHelper.ChartConfig;
private ChartHelperForPivot ChartHelper { get; } = new();
private string ChartClass => PivotDef == null || !ChartHelper.IsLineChart(PivotDef, PivotData)
? "d-none"
: ""
;
private string ColumnClass
{
get
{
//This is a little awkward, usually the table control will auto wrap long headers when they contain spaces, but we have some pivots currently that have
//a combination of long columns with and without spaces. Wrapping looks weird here, so we want to turn off wrapping if we can
//detect any column which has a long name without spaces
if ((_pivotRows[0].GetDynamicMemberNames().Count() <= 16
|| _pivotRows[0].GetDynamicMemberNames().Any(n => n.Length >= 10 && !n.Contains(" "))
)
)
return "table-nowrap";
else
return string.Empty;
}
}
private async Task<bool> OnCellClick(DynamicObject row, string fieldName)
{
try
{
await HandleCellClick(row, fieldName);
}
catch (Exception e)
{
await ModalDialogUtils.ShowExceptionDialog(Modal, $"Error drilling down into {fieldName}", e);
}
return true;
}
private readonly Dictionary<string, PivotColumnDescriptor> _descriptorsCache = [];
private readonly Dictionary<string, PivotFieldDescriptor?> _fieldDescriptorsCache = [];
private readonly PivotColumnDescriptor _defaultPivotColumnDescriptor = new()
{
Format = "N0",
NameRegexString = ".*",
Background = Color.FromName(Night.Background),
AlternateBackground = Color.FromName(Night.BackgroundLight)
};
private TableControl.SortModeType SortMode { get; set; }
private string? SortColumn { get; set; }
private PivotColumnDescriptor GetColumnDescriptorInternal(string columnName)
{
if (_descriptorsCache.TryGetValue(columnName, out var desc))
return desc;
_descriptors ??= SelectedCollectionNode?.ColumnDescriptors;
desc = GetColumnDescriptor(columnName)
?? _descriptors?.FirstOrDefault(x => x.NameRegex.IsMatch(columnName))
?? _defaultPivotColumnDescriptor;
_descriptorsCache[columnName] = desc;
return desc;
}
private PivotFieldDescriptor? GetFieldDescriptorInternal(string columnName)
{
if (_fieldDescriptorsCache.TryGetValue(columnName, out var desc))
return desc;
_fieldTypes ??= SelectedCollectionNode?.FieldTypes;
desc = GetFieldDescriptor(columnName);
if (desc == null)
_fieldTypes?.TryGetValue(columnName, out desc);
_fieldDescriptorsCache[columnName] = desc;
return desc;
}
private async Task CopyCsvInternal()
{
if (!IsExportEnabled)
return;
try
{
var csv = _pivotRows[0].PivotData.CopyToCsv();
await Js.InvokeVoidAsync("DashboardUtils.CopyToClipboard", csv);
}
catch (Exception e)
{
await ModalDialogUtils.ShowExceptionDialog(Modal, "Error copying pivot data", e);
}
}
private async Task ExportCsvInternal(string destFileName)
{
if (!IsExportEnabled)
return;
try
{
await using var writer = new StringWriter();
_pivotRows[0].PivotData.WriteToCsv(writer);
var data = writer.ToString();
var url = await PivotSharingService.ExportToCsv(
destFileName,
data
);
@* var url = await DownloadController.GetDownloadLink( *@
@* _storage, *@
@* _passwordManager, *@
@* _singleUseTokenService, *@
@* fileName => *@
@* { *@
@* _pivotRows![0].PivotData.WriteToCsv(fileName); *@
@* return Task.CompletedTask; *@
@* }, *@
@* destFileName *@
@* ); *@
await Js.InvokeVoidAsync("open", $"{NavigationManager.BaseUri}{url}", "_blank");
}
catch (Exception e)
{
await ModalDialogUtils.ShowExceptionDialog(Modal, "Error exporting pivot data", e);
}
}
// ReSharper disable once UnusedMember.Local
private List<dynamic> FilteredRows
{
get => _filteredRows;
set
{
_filteredRows = value;
_totals.Update(PivotData, _filteredRows.OfType<PivotRow>().Select( x => x.Row).ToArray());
if (_filteredRows.Count == 0 || PivotDef is not { MakeLineChart: true })
return;
_delayedUpdate.Run(UpdateFilteredChart, "UpdateFilteredChart");
}
}
private Task UpdateFilteredChart(CancellationToken token)
{
try
{
if (PivotDef == null || token.IsCancellationRequested)
return Task.CompletedTask;
var filteredPivot = new PivotFilteredView(_filteredRows.Cast<PivotRow>().ToList());
if (token.IsCancellationRequested)
return Task.CompletedTask;
ChartHelper.UpdateLineChart(PivotDef, filteredPivot, x => _pivotRows[0].GetFormat(x));
}
catch (Exception)
{
// ignore
}
return InvokeAsync(StateHasChanged);
}
public static string GetCellStyle(dynamic row, TableColumnControl col)
{
if ( row is not PivotRow r || string.IsNullOrWhiteSpace(col.Field) )
return "";
var desc = r.GetColumnDescriptor(col.Field);
if ( desc == null )
return "";
// for some reason comparison with 0x00000 and Color.Black are not working
if ( desc.Background is { R: 0, G: 0, B: 0 })
return "";
return $"background-color:#{desc.Background.A:x2}{desc.Background.R:x2}{desc.Background.G:x2}{desc.Background.B:x2}";
}
private string GetCellClassCallback(dynamic row, TableColumnControl col)
{
var cellClass = "";
// make a copy as GetFieldDescriptor may take noticeable time
var highlightTopPercent = PivotDef?.HighlightTopPercent ?? 0.0;
if (highlightTopPercent <= 0.0)
return cellClass;
if ( row is not PivotRow r || string.IsNullOrWhiteSpace(col.Field) )
return cellClass;
var mm = _totals.TryGet(col.Name);
if ( mm == null )
return cellClass;
var desc = r.GetFieldDescriptor(col.Field);
if ( desc != null && desc.Purpose != PivotFieldPurpose.Data )
return cellClass;
double val = GetCellValue(row, col.Field);
cellClass = val switch
{
> 0 when val > mm.MaxValue - mm.MaxValue * highlightTopPercent / 100.0 =>
$"{cellClass} glow-pos",
< 0 when val < mm.MinValue - mm.MinValue * highlightTopPercent / 100.0 =>
$"{cellClass} glow-neg",
_ => cellClass
};
// NaN here most likely means string column
if ( !double.IsNaN(val) && IsLastImportant(r, col.Name, mm) )
cellClass += " glow-last";
return cellClass;
}
private double GetCellValue(PivotRow row, string col)
{
if ( !_shadowHeaders.TryGetValue(col, out var idx) )
return double.NaN;
var val = row.PivotData.Get(idx, row.Row);
return val switch
{
double d => d,
int i => i,
_ => double.NaN
};
}
private bool IsLastImportant(PivotRow row, string colName, MinMax mm)
{
if ( SortColumn != colName || SortMode != TableControl.SortModeType.DescendingAbsolute)
return false;
var sum = 0.0;
var target = mm.AbsTotal * ( PivotDef?.HighlightTopPercent ?? 0.0 ) / 100.0;
foreach ( var r in FilteredRows.OfType<PivotRow>() )
{
var val = GetCellValue(r, colName);
if ( double.IsNaN(val) )
return false;
sum += Math.Abs(val);
if ( sum >= target )
return row.Row == r.Row;
}
return false;
}
}