Add finance 3D data analysis

This commit is contained in:
2026-06-04 13:36:03 +02:00
parent b44e8babf4
commit a8dc565478
8 changed files with 380 additions and 1 deletions
+2
View File
@@ -23,6 +23,8 @@
</script> </script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/download.js"></script> <script src="js/download.js"></script>
<script src="js/vendor/three.min.js"></script>
<script src="js/finance3d.js"></script>
</body> </body>
</html> </html>
@@ -39,6 +39,9 @@
<MudNavLink Href="management-cockpit?section=division&division=central" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.AccountTree"> <MudNavLink Href="management-cockpit?section=division&division=central" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.AccountTree">
@T("Zentrale Spartenzuordnung", "Central division mapping") @T("Zentrale Spartenzuordnung", "Central division mapping")
</MudNavLink> </MudNavLink>
<MudNavLink Href="management-cockpit?section=3d" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.ViewInAr">
@T("3D Datenanalyse", "3D data analysis")
</MudNavLink>
<MudNavLink Href="management-cockpit?section=raw" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.QueryStats"> <MudNavLink Href="management-cockpit?section=raw" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.QueryStats">
@T("Rohdaten Diagnose", "Raw-data diagnostics") @T("Rohdaten Diagnose", "Raw-data diagnostics")
</MudNavLink> </MudNavLink>
@@ -1,11 +1,13 @@
@page "/management-cockpit" @page "/management-cockpit"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer) @rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services @using TrafagSalesExporter.Services
@inject IManagementCockpitPageService CockpitPageService @inject IManagementCockpitPageService CockpitPageService
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IUiTextService UiText @inject IUiTextService UiText
@inject IJSRuntime JsRuntime
<PageTitle>@T("Management Analyse", "Management analysis")</PageTitle> <PageTitle>@T("Management Analyse", "Management analysis")</PageTitle>
@@ -648,6 +650,30 @@
</MudTabPanel> </MudTabPanel>
</MudTabs> </MudTabs>
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@T("3D Datenanalyse", "3D data analysis")" Icon="@Icons.Material.Filled.ViewInAr">
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="3">
<MudSelect T="string" Value="_finance3dIndicator" ValueChanged="SetFinance3dIndicator" Label="@T("Indikator", "Indicator")" Dense>
@foreach (var option in _finance3dIndicatorOptions)
{
<MudSelectItem Value="@option.Key">@T(option.GermanLabel, option.EnglishLabel)</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="9">
<MudText Typo="Typo.body2">
@T("Drehbar mit gedrueckter Maus, Zoom mit Mausrad. X-Achse = Land, Z-Achse = Jahr, Hoehe = gewaehlter Indikator.",
"Drag with the mouse to rotate, use the mouse wheel to zoom. X axis = country, Z axis = year, height = selected indicator.")
</MudText>
</MudItem>
</MudGrid>
</MudPaper>
<MudPaper Class="pa-0 finance-3d-surface" Elevation="1">
<canvas @ref="_finance3dCanvas" class="finance-3d-canvas"></canvas>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Rohdaten Diagnose", "Raw-data diagnostics")" Icon="@Icons.Material.Filled.QueryStats"> <MudTabPanel Text="@T("Rohdaten Diagnose", "Raw-data diagnostics")" Icon="@Icons.Material.Filled.QueryStats">
<MudPaper Class="pa-4 mb-4" Elevation="1"> <MudPaper Class="pa-4 mb-4" Elevation="1">
@@ -1015,6 +1041,13 @@
new(ProductFinanceGroupLevels.Family, "Produktfamilie", "Product family"), new(ProductFinanceGroupLevels.Family, "Produktfamilie", "Product family"),
new(ProductFinanceGroupLevels.Division, "Produktsparte", "Product division") new(ProductFinanceGroupLevels.Division, "Produktsparte", "Product division")
]; ];
private readonly List<Finance3dIndicatorOption> _finance3dIndicatorOptions =
[
new(Finance3dIndicators.Actual, "Net Sales Actual", "Net sales actual"),
new(Finance3dIndicators.IncludedRows, "Enthaltene Zeilen", "Included rows"),
new(Finance3dIndicators.ExcludedRows, "Ausgeschlossene Zeilen", "Excluded rows"),
new(Finance3dIndicators.Deviation, "Soll/Ist Differenz Filterjahr", "Actual/reference difference filter year")
];
private string? _selectedFilePath; private string? _selectedFilePath;
private ManagementCockpitResult? _result; private ManagementCockpitResult? _result;
private ManagementCockpitCentralResult? _centralResult; private ManagementCockpitCentralResult? _centralResult;
@@ -1040,6 +1073,9 @@
private int _activeDivisionTabIndex; private int _activeDivisionTabIndex;
private string _productFinanceGroupLevel = ProductFinanceGroupLevels.Hierarchy; private string _productFinanceGroupLevel = ProductFinanceGroupLevels.Hierarchy;
private bool _limitProductFinanceTop10; private bool _limitProductFinanceTop10;
private string _finance3dIndicator = Finance3dIndicators.Actual;
private ElementReference _finance3dCanvas;
private bool _finance3dNeedsRender;
private bool ShowProductFamilyColumn => _productFinanceGroupLevel != ProductFinanceGroupLevels.Division; private bool ShowProductFamilyColumn => _productFinanceGroupLevel != ProductFinanceGroupLevels.Division;
@@ -1068,6 +1104,7 @@
"deviations" => ManagementFinanceTabIndexes.Deviations, "deviations" => ManagementFinanceTabIndexes.Deviations,
"credits" => ManagementFinanceTabIndexes.Credits, "credits" => ManagementFinanceTabIndexes.Credits,
"quality" => ManagementFinanceTabIndexes.Quality, "quality" => ManagementFinanceTabIndexes.Quality,
"3d" => ManagementFinanceTabIndexes.ThreeD,
"raw" => ManagementFinanceTabIndexes.Raw, "raw" => ManagementFinanceTabIndexes.Raw,
_ => ManagementFinanceTabIndexes.Summary _ => ManagementFinanceTabIndexes.Summary
}; };
@@ -1086,6 +1123,15 @@
await AnalyzeFinanceSummary(); await AnalyzeFinanceSummary();
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_finance3dNeedsRender && _financeResult is not null)
{
_finance3dNeedsRender = false;
await RenderFinance3dAsync();
}
}
private async Task ReloadFiles() private async Task ReloadFiles()
{ {
_loadingFiles = true; _loadingFiles = true;
@@ -1174,6 +1220,7 @@
_financeCountryOptions = _financeResult.CountryOptions; _financeCountryOptions = _financeResult.CountryOptions;
_financeCurrencyOptions = _financeResult.CurrencyOptions; _financeCurrencyOptions = _financeResult.CurrencyOptions;
_selectedFinanceYear = _financeResult.Filter.Year; _selectedFinanceYear = _financeResult.Filter.Year;
_finance3dNeedsRender = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1196,6 +1243,71 @@
_limitProductFinanceTop10 = !_limitProductFinanceTop10; _limitProductFinanceTop10 = !_limitProductFinanceTop10;
} }
private async Task SetFinance3dIndicator(string value)
{
_finance3dIndicator = string.IsNullOrWhiteSpace(value) ? Finance3dIndicators.Actual : value;
await RenderFinance3dAsync();
}
private async Task RenderFinance3dAsync()
{
if (_financeResult is null)
return;
var rows = BuildFinance3dRows();
await JsRuntime.InvokeVoidAsync("trafagFinance3d.render", _finance3dCanvas, rows, new
{
indicator = _finance3dIndicator,
title = ResolveFinance3dIndicatorLabel(_finance3dIndicator)
});
}
private IReadOnlyList<object> BuildFinance3dRows()
{
if (_financeResult is null)
return [];
var deviationsByKey = _financeResult.CountryRows
.Where(row => row.Difference.HasValue)
.GroupBy(row => $"{row.Year}|{row.CountryKey}", StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.Sum(row => Math.Abs(row.Difference!.Value)));
var sourceRows = _finance3dIndicator == Finance3dIndicators.Deviation
? _financeResult.Rows
: (_financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows);
return sourceRows
.OrderBy(row => row.CountryKey, StringComparer.OrdinalIgnoreCase)
.ThenBy(row => row.Year)
.Select(row =>
{
deviationsByKey.TryGetValue($"{row.Year}|{row.CountryKey}", out var deviation);
var value = _finance3dIndicator switch
{
Finance3dIndicators.IncludedRows => row.IncludedRows,
Finance3dIndicators.ExcludedRows => row.ExcludedRows,
Finance3dIndicators.Deviation => deviation,
_ => Math.Abs(row.NetSalesActual)
};
return new
{
country = row.CountryKey,
year = row.Year,
currency = row.Currency,
value
};
})
.Cast<object>()
.ToList();
}
private string ResolveFinance3dIndicatorLabel(string key)
=> _finance3dIndicatorOptions.FirstOrDefault(option => option.Key == key) is { } option
? T(option.GermanLabel, option.EnglishLabel)
: T("Net Sales Actual", "Net sales actual");
private IReadOnlyList<ManagementProductDivisionFinanceRow> BuildProductFinanceRows() private IReadOnlyList<ManagementProductDivisionFinanceRow> BuildProductFinanceRows()
{ {
if (_financeResult is null) if (_financeResult is null)
@@ -1495,10 +1607,20 @@
public const int Credits = 4; public const int Credits = 4;
public const int Quality = 5; public const int Quality = 5;
public const int Division = 6; public const int Division = 6;
public const int Raw = 7; public const int ThreeD = 7;
public const int Raw = 8;
}
private static class Finance3dIndicators
{
public const string Actual = "actual";
public const string IncludedRows = "includedRows";
public const string ExcludedRows = "excludedRows";
public const string Deviation = "deviation";
} }
private sealed record ProductFinanceGroupingOption(string Key, string GermanLabel, string EnglishLabel); private sealed record ProductFinanceGroupingOption(string Key, string GermanLabel, string EnglishLabel);
private sealed record Finance3dIndicatorOption(string Key, string GermanLabel, string EnglishLabel);
private sealed record ProductFinanceGroupKey( private sealed record ProductFinanceGroupKey(
string ProductDivisionCode, string ProductDivisionCode,
@@ -314,6 +314,7 @@ public class ManagementFinanceSummaryResult
public List<string> CurrencyOptions { get; set; } = []; public List<string> CurrencyOptions { get; set; } = [];
public List<ManagementFinanceSummaryRow> Rows { get; set; } = []; public List<ManagementFinanceSummaryRow> Rows { get; set; } = [];
public List<ManagementFinanceSummaryRow> YearRows { get; set; } = []; public List<ManagementFinanceSummaryRow> YearRows { get; set; } = [];
public List<ManagementFinanceSummaryRow> YearCountryRows { get; set; } = [];
public int IncludedRows { get; set; } public int IncludedRows { get; set; }
public int ExcludedRows { get; set; } public int ExcludedRows { get; set; }
public int CountryCount { get; set; } public int CountryCount { get; set; }
@@ -443,6 +443,16 @@ public class ManagementCockpitService : IManagementCockpitService
.Select(group => BuildFinanceSummaryRow(group.Key.Year, "Alle", group.Key.Currency, group)) .Select(group => BuildFinanceSummaryRow(group.Key.Year, "Alle", group.Key.Currency, group))
.ToList(); .ToList();
var yearCountryRows = allRows
.Where(row => countryFilter is null || row.CountryKey.Equals(countryFilter, StringComparison.OrdinalIgnoreCase))
.Where(row => currencyFilter is null || row.Currency.Equals(currencyFilter, StringComparison.OrdinalIgnoreCase))
.GroupBy(row => new { row.Year, row.CountryKey, row.Currency })
.OrderBy(group => group.Key.CountryKey, StringComparer.OrdinalIgnoreCase)
.ThenBy(group => group.Key.Year)
.ThenBy(group => group.Key.Currency, StringComparer.OrdinalIgnoreCase)
.Select(group => BuildFinanceSummaryRow(group.Key.Year, group.Key.CountryKey, group.Key.Currency, group))
.ToList();
var includedRows = scopedRows.Count(row => row.Include); var includedRows = scopedRows.Count(row => row.Include);
var excludedRows = scopedRows.Count(row => !row.Include); var excludedRows = scopedRows.Count(row => !row.Include);
var resultCurrencies = summaryRows var resultCurrencies = summaryRows
@@ -501,6 +511,7 @@ public class ManagementCockpitService : IManagementCockpitService
.ToList(), .ToList(),
Rows = summaryRows, Rows = summaryRows,
YearRows = yearRows, YearRows = yearRows,
YearCountryRows = yearCountryRows,
IncludedRows = includedRows, IncludedRows = includedRows,
ExcludedRows = excludedRows, ExcludedRows = excludedRows,
CountryCount = summaryRows.Select(row => row.CountryKey).Distinct(StringComparer.OrdinalIgnoreCase).Count(), CountryCount = summaryRows.Select(row => row.CountryKey).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
+24
View File
@@ -39,3 +39,27 @@ body,
.language-button .mud-button-icon-end { .language-button .mud-button-icon-end {
color: #fff; color: #fff;
} }
.finance-3d-surface {
width: 100%;
min-height: 560px;
overflow: hidden;
background: #f7f9fb;
}
.finance-3d-canvas {
display: block;
width: 100%;
height: 560px;
touch-action: none;
}
@media (max-width: 700px) {
.finance-3d-surface {
min-height: 420px;
}
.finance-3d-canvas {
height: 420px;
}
}
+209
View File
@@ -0,0 +1,209 @@
(function () {
const stateByCanvas = new WeakMap();
function normalizeRows(rows) {
return Array.isArray(rows) ? rows.filter(row => row && Number.isFinite(Number(row.value))) : [];
}
function buildAxes(rows) {
const countries = [...new Set(rows.map(row => String(row.country || "-")))].sort();
const years = [...new Set(rows.map(row => Number(row.year || 0)))].sort((a, b) => a - b);
const maxValue = rows.reduce((max, row) => Math.max(max, Math.abs(Number(row.value || 0))), 0) || 1;
return { countries, years, maxValue };
}
function createThreeScene(canvas, rows, options) {
const THREE = window.THREE;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0xf7f9fb, 1);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
camera.position.set(18, 15, 22);
camera.lookAt(0, 0, 0);
const root = new THREE.Group();
scene.add(root);
scene.add(new THREE.AmbientLight(0xffffff, 0.72));
const light = new THREE.DirectionalLight(0xffffff, 0.75);
light.position.set(8, 18, 10);
scene.add(light);
const axes = buildAxes(rows);
const xStep = axes.countries.length > 1 ? 16 / (axes.countries.length - 1) : 0;
const zStep = axes.years.length > 1 ? 12 / (axes.years.length - 1) : 0;
const xStart = -8;
const zStart = -6;
const gridMaterial = new THREE.LineBasicMaterial({ color: 0xb7c1cc, transparent: true, opacity: 0.55 });
const gridPoints = [];
for (let i = 0; i <= axes.countries.length; i++) {
const x = xStart + (i - 0.5) * (xStep || 2);
gridPoints.push(new THREE.Vector3(x, 0, zStart - 1), new THREE.Vector3(x, 0, zStart + Math.max(1, axes.years.length - 1) * (zStep || 2) + 1));
}
for (let i = 0; i <= axes.years.length; i++) {
const z = zStart + (i - 0.5) * (zStep || 2);
gridPoints.push(new THREE.Vector3(xStart - 1, 0, z), new THREE.Vector3(xStart + Math.max(1, axes.countries.length - 1) * (xStep || 2) + 1, 0, z));
}
root.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(gridPoints), gridMaterial));
const barGeometry = new THREE.BoxGeometry(0.68, 1, 0.68);
rows.forEach(row => {
const countryIndex = Math.max(0, axes.countries.indexOf(String(row.country || "-")));
const yearIndex = Math.max(0, axes.years.indexOf(Number(row.year || 0)));
const rawValue = Math.abs(Number(row.value || 0));
const height = Math.max(0.08, rawValue / axes.maxValue * 8);
const material = new THREE.MeshStandardMaterial({
color: colorForValue(rawValue / axes.maxValue),
roughness: 0.58,
metalness: 0.05
});
const bar = new THREE.Mesh(barGeometry, material);
bar.scale.y = height;
bar.position.set(xStart + countryIndex * (xStep || 2), height / 2, zStart + yearIndex * (zStep || 2));
root.add(bar);
});
addCanvasLabel(scene, THREE, options.title || "", -8.8, 9.2, -7.8, 1.05);
axes.countries.forEach((country, index) => addCanvasLabel(scene, THREE, country, xStart + index * (xStep || 2), -0.15, zStart - 1.3, 0.58));
axes.years.forEach((year, index) => addCanvasLabel(scene, THREE, String(year), xStart - 1.6, -0.15, zStart + index * (zStep || 2), 0.58));
const state = { renderer, scene, camera, root, angleX: -0.62, angleY: 0.78, distance: 30, dragging: false, lastX: 0, lastY: 0 };
attachInteraction(canvas, state);
stateByCanvas.set(canvas, state);
resizeAndRender(canvas);
}
function addCanvasLabel(scene, THREE, text, x, y, z, scale) {
const labelCanvas = document.createElement("canvas");
labelCanvas.width = 512;
labelCanvas.height = 128;
const ctx = labelCanvas.getContext("2d");
ctx.clearRect(0, 0, labelCanvas.width, labelCanvas.height);
ctx.fillStyle = "#243241";
ctx.font = "600 44px Open Sans, Arial, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(text || "-"), 256, 64, 480);
const texture = new THREE.CanvasTexture(labelCanvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(material);
sprite.position.set(x, y, z);
sprite.scale.set(3.5 * scale, 0.85 * scale, 1);
scene.add(sprite);
}
function colorForValue(t) {
const clamped = Math.max(0, Math.min(1, t));
const r = Math.round(45 + clamped * 178);
const g = Math.round(105 + clamped * 78);
const b = Math.round(155 - clamped * 88);
return (r << 16) + (g << 8) + b;
}
function attachInteraction(canvas, state) {
canvas.onpointerdown = event => {
state.dragging = true;
state.lastX = event.clientX;
state.lastY = event.clientY;
canvas.setPointerCapture(event.pointerId);
};
canvas.onpointermove = event => {
if (!state.dragging) return;
const dx = event.clientX - state.lastX;
const dy = event.clientY - state.lastY;
state.lastX = event.clientX;
state.lastY = event.clientY;
state.angleY += dx * 0.008;
state.angleX = Math.max(-1.25, Math.min(-0.15, state.angleX + dy * 0.006));
renderState(state, canvas);
};
canvas.onpointerup = event => {
state.dragging = false;
try { canvas.releasePointerCapture(event.pointerId); } catch { }
};
canvas.onwheel = event => {
event.preventDefault();
state.distance = Math.max(16, Math.min(54, state.distance + event.deltaY * 0.025));
renderState(state, canvas);
};
}
function renderState(state, canvas) {
const width = canvas.clientWidth || 900;
const height = canvas.clientHeight || 520;
state.camera.aspect = width / height;
state.camera.updateProjectionMatrix();
const horizontal = Math.cos(state.angleX) * state.distance;
state.camera.position.set(Math.sin(state.angleY) * horizontal, Math.sin(-state.angleX) * state.distance, Math.cos(state.angleY) * horizontal);
state.camera.lookAt(0, 2.8, 0);
state.renderer.setSize(width, height, false);
state.renderer.render(state.scene, state.camera);
}
function renderFallback(canvas, rows, options) {
const ctx = canvas.getContext("2d");
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const width = rect.width;
const height = rect.height;
ctx.fillStyle = "#f7f9fb";
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#243241";
ctx.font = "600 16px Open Sans, Arial, sans-serif";
ctx.fillText(options.title || "3D data analysis", 18, 28);
const axes = buildAxes(rows);
const barWidth = Math.max(12, Math.min(42, (width - 80) / Math.max(1, rows.length) * 0.72));
rows.forEach((row, index) => {
const value = Math.abs(Number(row.value || 0));
const barHeight = value / axes.maxValue * (height - 110);
const x = 44 + index * ((width - 90) / Math.max(1, rows.length));
const y = height - 42 - barHeight;
ctx.fillStyle = `hsl(${205 - (value / axes.maxValue) * 115}, 58%, 45%)`;
ctx.fillRect(x, y, barWidth, barHeight);
});
ctx.fillStyle = "#52606d";
ctx.font = "12px Open Sans, Arial, sans-serif";
ctx.fillText("Fallback canvas renderer: Three.js could not be loaded.", 18, height - 16);
}
function resizeAndRender(canvas) {
const state = stateByCanvas.get(canvas);
if (state) renderState(state, canvas);
}
window.trafagFinance3d = {
render: function (canvas, rows, options) {
if (!canvas) return;
const normalizedRows = normalizeRows(rows);
if (normalizedRows.length === 0) return;
const existing = stateByCanvas.get(canvas);
if (existing && existing.renderer && existing.renderer.dispose) {
existing.renderer.dispose();
}
if (window.THREE && window.THREE.WebGLRenderer) {
createThreeScene(canvas, normalizedRows, options || {});
} else {
renderFallback(canvas, normalizedRows, options || {});
}
},
resize: resizeAndRender,
pixelProbe: function (canvas) {
const ctx = canvas && canvas.getContext ? canvas.getContext("2d", { willReadFrequently: true }) : null;
if (!ctx) return -1;
const data = ctx.getImageData(0, 0, Math.min(32, canvas.width), Math.min(32, canvas.height)).data;
let sum = 0;
for (let i = 0; i < data.length; i += 4) sum += data[i] + data[i + 1] + data[i + 2];
return sum;
}
};
window.addEventListener("resize", () => {
document.querySelectorAll(".finance-3d-canvas").forEach(canvas => resizeAndRender(canvas));
});
})();
File diff suppressed because one or more lines are too long