diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor index 59500e5..8d48560 100644 --- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor +++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor @@ -1,5 +1,6 @@ @page "/management-cockpit" @rendermode @(Microsoft.AspNetCore.Components.Web.RenderMode.InteractiveServer) +@using System.Globalization @using Microsoft.AspNetCore.Components @using Microsoft.JSInterop @using TrafagSalesExporter.Models @@ -661,10 +662,44 @@ } - + + @T("Szenario-Faktor / Wechselkurs", "Scenario factor / exchange rate") +
+ -10% + + @_finance3dScenarioFactor.ToString("0.00", CultureInfo.InvariantCulture)x + + +10% +
+ @if (Finance3dScenarioAffectsValue) + { + + @T("Basis", "Base"): @FormatFinance3dValue(Finance3dBaseTotal) | + @T("Szenario", "Scenario"): @FormatFinance3dValue(Finance3dScenarioTotal) | + @T("Delta", "Delta"): @FormatFinance3dValue(Finance3dScenarioDelta) + + } + else + { + + @T("Der Faktor wirkt nur auf Wertindikatoren, nicht auf Zeilenanzahlen.", + "The factor only affects value indicators, not row counts.") + + } +
+ - @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.") + @T("Linke Maustaste drehen, Mausrad zoomen, Shift+Ziehen oder rechte Maustaste verschieben. X-Achse = Land, Z-Achse = Jahr, Hoehe = gewaehlter Indikator.", + "Left mouse button rotates, mouse wheel zooms, Shift+drag or right mouse button pans. X axis = country, Z axis = year, height = selected indicator.") @@ -1074,10 +1109,17 @@ private string _productFinanceGroupLevel = ProductFinanceGroupLevels.Hierarchy; private bool _limitProductFinanceTop10; private string _finance3dIndicator = Finance3dIndicators.Actual; + private double _finance3dScenarioFactor = 1d; private ElementReference _finance3dCanvas; private bool _finance3dNeedsRender; 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; @@ -1249,6 +1291,27 @@ 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() { if (_financeResult is null) @@ -1288,8 +1351,8 @@ { Finance3dIndicators.IncludedRows => row.IncludedRows, Finance3dIndicators.ExcludedRows => row.ExcludedRows, - Finance3dIndicators.Deviation => deviation, - _ => Math.Abs(row.NetSalesActual) + Finance3dIndicators.Deviation => deviation * (decimal)_finance3dScenarioFactor, + _ => Math.Abs(row.NetSalesActual) * (decimal)_finance3dScenarioFactor }; return new { @@ -1303,6 +1366,33 @@ .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) => _finance3dIndicatorOptions.FirstOrDefault(option => option.Key == key) is { } option ? T(option.GermanLabel, option.EnglishLabel) diff --git a/TrafagSalesExporter/wwwroot/css/app.css b/TrafagSalesExporter/wwwroot/css/app.css index 297bd5c..785e81d 100644 --- a/TrafagSalesExporter/wwwroot/css/app.css +++ b/TrafagSalesExporter/wwwroot/css/app.css @@ -54,6 +54,23 @@ body, 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) { .finance-3d-surface { min-height: 420px; diff --git a/TrafagSalesExporter/wwwroot/js/finance3d.js b/TrafagSalesExporter/wwwroot/js/finance3d.js index 406644e..e5385bd 100644 --- a/TrafagSalesExporter/wwwroot/js/finance3d.js +++ b/TrafagSalesExporter/wwwroot/js/finance3d.js @@ -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.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); stateByCanvas.set(canvas, state); resizeAndRender(canvas); @@ -104,7 +120,9 @@ function attachInteraction(canvas, state) { canvas.onpointerdown = event => { + event.preventDefault(); state.dragging = true; + state.dragMode = event.button === 2 || event.button === 1 || event.shiftKey ? "pan" : "rotate"; state.lastX = event.clientX; state.lastY = event.clientY; canvas.setPointerCapture(event.pointerId); @@ -115,29 +133,66 @@ 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)); + if (state.dragMode === "pan") { + panCamera(state, dx, dy); + } else { + 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.onpointercancel = () => { + state.dragging = false; + }; + canvas.oncontextmenu = event => { + event.preventDefault(); + }; canvas.onwheel = event => { 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); }; } + 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) { 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.camera.position.set( + 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.render(state.scene, state.camera); }