Improve finance 3D controls and simulation

This commit is contained in:
2026-06-04 13:42:38 +02:00
parent a8dc565478
commit 13a7331f3d
3 changed files with 173 additions and 11 deletions
@@ -1,5 +1,6 @@
@page "/management-cockpit" @page "/management-cockpit"
@rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer) @rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer)
@using System.Globalization
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
@@ -661,10 +662,44 @@
} }
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="9"> <MudItem xs="12" md="4">
<MudText Typo="Typo.caption">@T("Szenario-Faktor / Wechselkurs", "Scenario factor / exchange rate")</MudText>
<div class="finance-3d-range-row">
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => SetFinance3dScenarioFactorPreset(0.9d))">-10%</MudButton>
<input class="finance-3d-range"
type="range"
min="0.5"
max="1.5"
step="0.01"
value="@_finance3dScenarioFactor.ToString("0.00", CultureInfo.InvariantCulture)"
@oninput="SetFinance3dScenarioFactor" />
<MudText Typo="Typo.body2" Class="finance-3d-factor">@_finance3dScenarioFactor.ToString("0.00", CultureInfo.InvariantCulture)x</MudText>
<MudIconButton Icon="@Icons.Material.Filled.RestartAlt"
Size="Size.Small"
Color="Color.Default"
OnClick="ResetFinance3dScenarioFactor" />
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => SetFinance3dScenarioFactorPreset(1.1d))">+10%</MudButton>
</div>
@if (Finance3dScenarioAffectsValue)
{
<MudText Typo="Typo.caption">
@T("Basis", "Base"): @FormatFinance3dValue(Finance3dBaseTotal) |
@T("Szenario", "Scenario"): @FormatFinance3dValue(Finance3dScenarioTotal) |
@T("Delta", "Delta"): @FormatFinance3dValue(Finance3dScenarioDelta)
</MudText>
}
else
{
<MudText Typo="Typo.caption">
@T("Der Faktor wirkt nur auf Wertindikatoren, nicht auf Zeilenanzahlen.",
"The factor only affects value indicators, not row counts.")
</MudText>
}
</MudItem>
<MudItem xs="12" md="5">
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">
@T("Drehbar mit gedrueckter Maus, Zoom mit Mausrad. X-Achse = Land, Z-Achse = Jahr, Hoehe = gewaehlter Indikator.", @T("Linke Maustaste drehen, Mausrad zoomen, Shift+Ziehen oder rechte Maustaste verschieben. 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.") "Left mouse button rotates, mouse wheel zooms, Shift+drag or right mouse button pans. X axis = country, Z axis = year, height = selected indicator.")
</MudText> </MudText>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
@@ -1074,10 +1109,17 @@
private string _productFinanceGroupLevel = ProductFinanceGroupLevels.Hierarchy; private string _productFinanceGroupLevel = ProductFinanceGroupLevels.Hierarchy;
private bool _limitProductFinanceTop10; private bool _limitProductFinanceTop10;
private string _finance3dIndicator = Finance3dIndicators.Actual; private string _finance3dIndicator = Finance3dIndicators.Actual;
private double _finance3dScenarioFactor = 1d;
private ElementReference _finance3dCanvas; private ElementReference _finance3dCanvas;
private bool _finance3dNeedsRender; private bool _finance3dNeedsRender;
private bool ShowProductFamilyColumn => _productFinanceGroupLevel != ProductFinanceGroupLevels.Division; private bool ShowProductFamilyColumn => _productFinanceGroupLevel != ProductFinanceGroupLevels.Division;
private bool Finance3dScenarioAffectsValue => _finance3dIndicator is Finance3dIndicators.Actual or Finance3dIndicators.Deviation;
private decimal Finance3dBaseTotal => CalculateFinance3dBaseTotal();
private decimal Finance3dScenarioTotal => Finance3dScenarioAffectsValue
? Finance3dBaseTotal * (decimal)_finance3dScenarioFactor
: Finance3dBaseTotal;
private decimal Finance3dScenarioDelta => Finance3dScenarioTotal - Finance3dBaseTotal;
private bool ShowProductHierarchyColumn => _productFinanceGroupLevel == ProductFinanceGroupLevels.Hierarchy; private bool ShowProductHierarchyColumn => _productFinanceGroupLevel == ProductFinanceGroupLevels.Hierarchy;
@@ -1249,6 +1291,27 @@
await RenderFinance3dAsync(); await RenderFinance3dAsync();
} }
private async Task SetFinance3dScenarioFactor(ChangeEventArgs args)
{
if (double.TryParse(Convert.ToString(args.Value, CultureInfo.InvariantCulture), NumberStyles.Number, CultureInfo.InvariantCulture, out var value))
{
_finance3dScenarioFactor = Math.Clamp(value, 0.5d, 1.5d);
await RenderFinance3dAsync();
}
}
private async Task ResetFinance3dScenarioFactor()
{
_finance3dScenarioFactor = 1d;
await RenderFinance3dAsync();
}
private async Task SetFinance3dScenarioFactorPreset(double value)
{
_finance3dScenarioFactor = Math.Clamp(value, 0.5d, 1.5d);
await RenderFinance3dAsync();
}
private async Task RenderFinance3dAsync() private async Task RenderFinance3dAsync()
{ {
if (_financeResult is null) if (_financeResult is null)
@@ -1288,8 +1351,8 @@
{ {
Finance3dIndicators.IncludedRows => row.IncludedRows, Finance3dIndicators.IncludedRows => row.IncludedRows,
Finance3dIndicators.ExcludedRows => row.ExcludedRows, Finance3dIndicators.ExcludedRows => row.ExcludedRows,
Finance3dIndicators.Deviation => deviation, Finance3dIndicators.Deviation => deviation * (decimal)_finance3dScenarioFactor,
_ => Math.Abs(row.NetSalesActual) _ => Math.Abs(row.NetSalesActual) * (decimal)_finance3dScenarioFactor
}; };
return new return new
{ {
@@ -1303,6 +1366,33 @@
.ToList(); .ToList();
} }
private decimal CalculateFinance3dBaseTotal()
{
if (_financeResult is null)
return 0m;
var sourceRows = _finance3dIndicator == Finance3dIndicators.Deviation
? _financeResult.Rows
: (_financeResult.YearCountryRows.Count > 0 ? _financeResult.YearCountryRows : _financeResult.Rows);
if (_finance3dIndicator == Finance3dIndicators.Deviation)
{
return _financeResult.CountryRows
.Where(row => row.Difference.HasValue)
.Sum(row => Math.Abs(row.Difference!.Value));
}
return _finance3dIndicator switch
{
Finance3dIndicators.IncludedRows => sourceRows.Sum(row => row.IncludedRows),
Finance3dIndicators.ExcludedRows => sourceRows.Sum(row => row.ExcludedRows),
_ => sourceRows.Sum(row => Math.Abs(row.NetSalesActual))
};
}
private string FormatFinance3dValue(decimal value)
=> value.ToString("N0", CultureInfo.CurrentCulture);
private string ResolveFinance3dIndicatorLabel(string key) private string ResolveFinance3dIndicatorLabel(string key)
=> _finance3dIndicatorOptions.FirstOrDefault(option => option.Key == key) is { } option => _finance3dIndicatorOptions.FirstOrDefault(option => option.Key == key) is { } option
? T(option.GermanLabel, option.EnglishLabel) ? T(option.GermanLabel, option.EnglishLabel)
+17
View File
@@ -54,6 +54,23 @@ body,
touch-action: none; touch-action: none;
} }
.finance-3d-range-row {
align-items: center;
display: flex;
gap: 10px;
min-height: 38px;
}
.finance-3d-range {
flex: 1 1 auto;
min-width: 120px;
}
.finance-3d-factor {
min-width: 48px;
text-align: right;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.finance-3d-surface { .finance-3d-surface {
min-height: 420px; min-height: 420px;
+59 -4
View File
@@ -69,7 +69,23 @@
axes.countries.forEach((country, index) => addCanvasLabel(scene, THREE, country, xStart + index * (xStep || 2), -0.15, zStart - 1.3, 0.58)); 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)); 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 }; const previous = stateByCanvas.get(canvas);
const state = {
renderer,
scene,
camera,
root,
angleX: previous ? previous.angleX : -0.62,
angleY: previous ? previous.angleY : 0.78,
distance: previous ? previous.distance : 30,
targetX: previous ? previous.targetX : 0,
targetY: previous ? previous.targetY : 2.8,
targetZ: previous ? previous.targetZ : 0,
dragging: false,
dragMode: "rotate",
lastX: 0,
lastY: 0
};
attachInteraction(canvas, state); attachInteraction(canvas, state);
stateByCanvas.set(canvas, state); stateByCanvas.set(canvas, state);
resizeAndRender(canvas); resizeAndRender(canvas);
@@ -104,7 +120,9 @@
function attachInteraction(canvas, state) { function attachInteraction(canvas, state) {
canvas.onpointerdown = event => { canvas.onpointerdown = event => {
event.preventDefault();
state.dragging = true; state.dragging = true;
state.dragMode = event.button === 2 || event.button === 1 || event.shiftKey ? "pan" : "rotate";
state.lastX = event.clientX; state.lastX = event.clientX;
state.lastY = event.clientY; state.lastY = event.clientY;
canvas.setPointerCapture(event.pointerId); canvas.setPointerCapture(event.pointerId);
@@ -115,29 +133,66 @@
const dy = event.clientY - state.lastY; const dy = event.clientY - state.lastY;
state.lastX = event.clientX; state.lastX = event.clientX;
state.lastY = event.clientY; state.lastY = event.clientY;
if (state.dragMode === "pan") {
panCamera(state, dx, dy);
} else {
state.angleY += dx * 0.008; state.angleY += dx * 0.008;
state.angleX = Math.max(-1.25, Math.min(-0.15, state.angleX + dy * 0.006)); state.angleX = Math.max(-1.25, Math.min(-0.15, state.angleX + dy * 0.006));
}
renderState(state, canvas); renderState(state, canvas);
}; };
canvas.onpointerup = event => { canvas.onpointerup = event => {
state.dragging = false; state.dragging = false;
try { canvas.releasePointerCapture(event.pointerId); } catch { } try { canvas.releasePointerCapture(event.pointerId); } catch { }
}; };
canvas.onpointercancel = () => {
state.dragging = false;
};
canvas.oncontextmenu = event => {
event.preventDefault();
};
canvas.onwheel = event => { canvas.onwheel = event => {
event.preventDefault(); event.preventDefault();
state.distance = Math.max(16, Math.min(54, state.distance + event.deltaY * 0.025)); const delta = normalizeWheelDelta(event);
const zoomFactor = delta > 0 ? 1.12 : 0.88;
state.distance = Math.max(14, Math.min(62, state.distance * zoomFactor));
renderState(state, canvas); renderState(state, canvas);
}; };
} }
function normalizeWheelDelta(event) {
if (Number.isFinite(event.deltaY) && event.deltaY !== 0) {
return event.deltaY > 0 ? 1 : -1;
}
if (Number.isFinite(event.wheelDelta) && event.wheelDelta !== 0) {
return event.wheelDelta < 0 ? 1 : -1;
}
return 1;
}
function panCamera(state, dx, dy) {
const scale = state.distance * 0.0018;
const rightX = Math.cos(state.angleY);
const rightZ = -Math.sin(state.angleY);
const forwardX = Math.sin(state.angleY);
const forwardZ = Math.cos(state.angleY);
state.targetX -= dx * scale * rightX;
state.targetZ -= dx * scale * rightZ;
state.targetX += dy * scale * forwardX;
state.targetZ += dy * scale * forwardZ;
}
function renderState(state, canvas) { function renderState(state, canvas) {
const width = canvas.clientWidth || 900; const width = canvas.clientWidth || 900;
const height = canvas.clientHeight || 520; const height = canvas.clientHeight || 520;
state.camera.aspect = width / height; state.camera.aspect = width / height;
state.camera.updateProjectionMatrix(); state.camera.updateProjectionMatrix();
const horizontal = Math.cos(state.angleX) * state.distance; 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.position.set(
state.camera.lookAt(0, 2.8, 0); state.targetX + Math.sin(state.angleY) * horizontal,
state.targetY + Math.sin(-state.angleX) * state.distance,
state.targetZ + Math.cos(state.angleY) * horizontal);
state.camera.lookAt(state.targetX, state.targetY, state.targetZ);
state.renderer.setSize(width, height, false); state.renderer.setSize(width, height, false);
state.renderer.render(state.scene, state.camera); state.renderer.render(state.scene, state.camera);
} }