diff --git a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor index 89844ba..8c89724 100644 --- a/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor +++ b/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor @@ -662,6 +662,14 @@ } + + + @foreach (var option in _finance3dChartTypeOptions) + { + @T(option.GermanLabel, option.EnglishLabel) + } + + @T("Szenario-Faktor / Wechselkurs", "Scenario factor / exchange rate")
@@ -696,10 +704,10 @@ } - + - @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.") + @T("Linke Maustaste drehen, Mausrad zoomen, Shift+Ziehen oder rechte Maustaste verschieben. Balken/Linie/Flaeche zeigen Land-Jahr-Verlauf, Kreis zeigt Laenderanteile.", + "Left mouse button rotates, mouse wheel zooms, Shift+drag or right mouse button pans. Bar/line/surface show country-year trend, pie shows country shares.") @@ -1083,6 +1091,13 @@ new(Finance3dIndicators.ExcludedRows, "Ausgeschlossene Zeilen", "Excluded rows"), new(Finance3dIndicators.Deviation, "Soll/Ist Differenz Filterjahr", "Actual/reference difference filter year") ]; + private readonly List _finance3dChartTypeOptions = + [ + new(Finance3dChartTypes.Bar, "Balken", "Bar"), + new(Finance3dChartTypes.Line, "Linie", "Line"), + new(Finance3dChartTypes.Surface, "Flaeche", "Surface"), + new(Finance3dChartTypes.Pie, "Kreis / Anteil", "Pie / share") + ]; private string? _selectedFilePath; private ManagementCockpitResult? _result; private ManagementCockpitCentralResult? _centralResult; @@ -1109,6 +1124,7 @@ private string _productFinanceGroupLevel = ProductFinanceGroupLevels.Hierarchy; private bool _limitProductFinanceTop10; private string _finance3dIndicator = Finance3dIndicators.Actual; + private string _finance3dChartType = Finance3dChartTypes.Bar; private double _finance3dScenarioFactor = 1d; private ElementReference _finance3dCanvas; private bool _finance3dNeedsRender; @@ -1291,6 +1307,12 @@ await RenderFinance3dAsync(); } + private async Task SetFinance3dChartType(string value) + { + _finance3dChartType = string.IsNullOrWhiteSpace(value) ? Finance3dChartTypes.Bar : value; + await RenderFinance3dAsync(); + } + private async Task SetFinance3dScenarioFactor(ChangeEventArgs args) { if (double.TryParse(Convert.ToString(args.Value, CultureInfo.InvariantCulture), NumberStyles.Number, CultureInfo.InvariantCulture, out var value)) @@ -1322,6 +1344,7 @@ { indicator = _finance3dIndicator, title = ResolveFinance3dIndicatorLabel(_finance3dIndicator), + chartType = _finance3dChartType, scenarioFactor = Finance3dScenarioAffectsValue ? _finance3dScenarioFactor : 1d }); } @@ -1718,8 +1741,17 @@ public const string Deviation = "deviation"; } + private static class Finance3dChartTypes + { + public const string Bar = "bar"; + public const string Line = "line"; + public const string Surface = "surface"; + public const string Pie = "pie"; + } + private sealed record ProductFinanceGroupingOption(string Key, string GermanLabel, string EnglishLabel); private sealed record Finance3dIndicatorOption(string Key, string GermanLabel, string EnglishLabel); + private sealed record Finance3dChartTypeOption(string Key, string GermanLabel, string EnglishLabel); private sealed record ProductFinanceGroupKey( string ProductDivisionCode, diff --git a/TrafagSalesExporter/wwwroot/js/finance3d.js b/TrafagSalesExporter/wwwroot/js/finance3d.js index 8ccc041..41b713d 100644 --- a/TrafagSalesExporter/wwwroot/js/finance3d.js +++ b/TrafagSalesExporter/wwwroot/js/finance3d.js @@ -15,6 +15,7 @@ function createThreeScene(canvas, rows, options) { const THREE = window.THREE; const factor = normalizeFactor(options && options.scenarioFactor); + const chartType = normalizeChartType(options && options.chartType); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setClearColor(0xf7f9fb, 1); @@ -49,25 +50,18 @@ } root.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(gridPoints), gridMaterial)); - const barGeometry = new THREE.BoxGeometry(0.68, 1, 0.68); - const bars = []; - 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.userData.baseHeight = height; - bar.position.set(xStart + countryIndex * (xStep || 2), 0, zStart + yearIndex * (zStep || 2)); - applyBarFactor(bar, factor); - bars.push(bar); - root.add(bar); - }); + const scalables = []; + const layout = { axes, xStep, zStep, xStart, zStart }; + if (chartType === "line") { + createLineChart(THREE, root, rows, layout, scalables); + } else if (chartType === "surface") { + createSurfaceChart(THREE, root, rows, layout, scalables); + } else if (chartType === "pie") { + createPieChart(THREE, root, rows, layout, scalables); + } else { + createBarChart(THREE, root, rows, layout, scalables); + } + applyFactorToScalables(scalables, factor); 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)); @@ -86,7 +80,7 @@ targetY: previous ? previous.targetY : 2.8, targetZ: previous ? previous.targetZ : 0, factor, - bars, + scalables, dragging: false, dragMode: "rotate", lastX: 0, @@ -103,12 +97,207 @@ return Math.max(0.5, Math.min(1.5, factor)); } + function normalizeChartType(value) { + const text = String(value || "bar").toLowerCase(); + return ["bar", "line", "surface", "pie"].includes(text) ? text : "bar"; + } + + function rowPosition(row, layout) { + const countryIndex = Math.max(0, layout.axes.countries.indexOf(String(row.country || "-"))); + const yearIndex = Math.max(0, layout.axes.years.indexOf(Number(row.year || 0))); + const rawValue = Math.abs(Number(row.value || 0)); + return { + x: layout.xStart + countryIndex * (layout.xStep || 2), + z: layout.zStart + yearIndex * (layout.zStep || 2), + baseHeight: Math.max(0.08, rawValue / layout.axes.maxValue * 8), + ratio: rawValue / layout.axes.maxValue, + country: String(row.country || "-"), + year: Number(row.year || 0), + value: rawValue + }; + } + + function createBarChart(THREE, root, rows, layout, scalables) { + const barGeometry = new THREE.BoxGeometry(0.68, 1, 0.68); + rows.forEach(row => { + const p = rowPosition(row, layout); + const material = new THREE.MeshStandardMaterial({ + color: colorForValue(p.ratio), + roughness: 0.58, + metalness: 0.05 + }); + const bar = new THREE.Mesh(barGeometry, material); + bar.userData.baseHeight = p.baseHeight; + bar.position.set(p.x, 0, p.z); + scalables.push({ type: "bar", object: bar }); + root.add(bar); + }); + } + + function createLineChart(THREE, root, rows, layout, scalables) { + const pointGeometry = new THREE.SphereGeometry(0.22, 16, 12); + const lineMaterial = new THREE.LineBasicMaterial({ color: 0x2f6f9f, linewidth: 2 }); + const sortedGroups = groupRowsByCountry(rows, layout); + sortedGroups.forEach((points, groupIndex) => { + const material = new THREE.MeshStandardMaterial({ color: colorForSeries(groupIndex), roughness: 0.5 }); + const linePositions = []; + points.forEach(point => { + const marker = new THREE.Mesh(pointGeometry, material); + marker.userData.baseHeight = point.baseHeight; + marker.userData.baseX = point.x; + marker.userData.baseZ = point.z; + scalables.push({ type: "point", object: marker }); + root.add(marker); + linePositions.push(point.x, point.baseHeight, point.z); + }); + if (linePositions.length >= 6) { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(linePositions, 3)); + const line = new THREE.Line(geometry, lineMaterial.clone()); + line.userData.basePositions = linePositions.slice(); + scalables.push({ type: "line", object: line }); + root.add(line); + } + }); + } + + function createSurfaceChart(THREE, root, rows, layout, scalables) { + const rowByKey = new Map(rows.map(row => [`${String(row.country || "-")}|${Number(row.year || 0)}`, rowPosition(row, layout)])); + const vertices = []; + const indices = []; + layout.axes.countries.forEach(country => { + layout.axes.years.forEach(year => { + const p = rowByKey.get(`${country}|${year}`) || { + x: layout.xStart + Math.max(0, layout.axes.countries.indexOf(country)) * (layout.xStep || 2), + z: layout.zStart + Math.max(0, layout.axes.years.indexOf(year)) * (layout.zStep || 2), + baseHeight: 0.04 + }; + vertices.push(p.x, p.baseHeight, p.z); + }); + }); + const yearCount = layout.axes.years.length; + for (let c = 0; c < layout.axes.countries.length - 1; c++) { + for (let y = 0; y < layout.axes.years.length - 1; y++) { + const a = c * yearCount + y; + const b = (c + 1) * yearCount + y; + const d = c * yearCount + y + 1; + const e = (c + 1) * yearCount + y + 1; + indices.push(a, b, d, b, e, d); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(vertices, 3)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + const material = new THREE.MeshStandardMaterial({ + color: 0x4188a8, + roughness: 0.64, + metalness: 0.03, + opacity: 0.72, + transparent: true, + side: THREE.DoubleSide + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.userData.basePositions = vertices.slice(); + scalables.push({ type: "surface", object: mesh }); + root.add(mesh); + + const wire = new THREE.LineSegments(new THREE.WireframeGeometry(geometry), new THREE.LineBasicMaterial({ color: 0x24485a, transparent: true, opacity: 0.42 })); + wire.userData.sourceGeometry = geometry; + scalables.push({ type: "wire", object: wire, source: mesh }); + root.add(wire); + } + + function createPieChart(THREE, root, rows, layout, scalables) { + const totals = [...rows.reduce((map, row) => { + const country = String(row.country || "-"); + map.set(country, (map.get(country) || 0) + Math.abs(Number(row.value || 0))); + return map; + }, new Map())] + .filter(([, value]) => value > 0) + .sort((a, b) => b[1] - a[1]); + const total = totals.reduce((sum, [, value]) => sum + value, 0) || 1; + let start = -Math.PI / 2; + totals.forEach(([country, value], index) => { + const angle = value / total * Math.PI * 2; + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + const steps = Math.max(8, Math.ceil(angle / (Math.PI / 20))); + for (let i = 0; i <= steps; i++) { + const a = start + angle * i / steps; + shape.lineTo(Math.cos(a) * 6, Math.sin(a) * 6); + } + shape.lineTo(0, 0); + const geometry = new THREE.ExtrudeGeometry(shape, { depth: 0.35, bevelEnabled: false }); + const material = new THREE.MeshStandardMaterial({ color: colorForSeries(index), roughness: 0.55 }); + const slice = new THREE.Mesh(geometry, material); + slice.rotation.x = -Math.PI / 2; + slice.position.y = 0.04; + slice.userData.baseHeight = 0.35; + scalables.push({ type: "pie", object: slice }); + root.add(slice); + + const labelAngle = start + angle / 2; + addCanvasLabel(root, THREE, country, Math.cos(labelAngle) * 7.1, 0.25, Math.sin(labelAngle) * 7.1, 0.5); + start += angle; + }); + } + + function groupRowsByCountry(rows, layout) { + const groups = new Map(); + rows.forEach(row => { + const p = rowPosition(row, layout); + if (!groups.has(p.country)) groups.set(p.country, []); + groups.get(p.country).push(p); + }); + return [...groups.values()].map(points => points.sort((a, b) => a.year - b.year)); + } + + function applyFactorToScalables(scalables, factor) { + scalables.forEach(item => { + if (item.type === "bar") { + applyBarFactor(item.object, factor); + } else if (item.type === "point") { + applyPointFactor(item.object, factor); + } else if (item.type === "line" || item.type === "surface") { + applyPositionFactor(item.object, factor); + } else if (item.type === "wire") { + item.object.geometry.dispose(); + item.object.geometry = new window.THREE.WireframeGeometry(item.source.geometry); + } else if (item.type === "pie") { + item.object.scale.z = factor; + } + }); + } + function applyBarFactor(bar, factor) { const height = Math.max(0.02, Number(bar.userData.baseHeight || 0.08) * factor); bar.scale.y = height; bar.position.y = height / 2; } + function applyPointFactor(point, factor) { + point.scale.set(1, 1, 1); + point.position.set( + Number(point.userData.baseX || 0), + Math.max(0.02, Number(point.userData.baseHeight || 0.08) * factor), + Number(point.userData.baseZ || 0)); + } + + function applyPositionFactor(object, factor) { + const base = object.userData.basePositions; + const attribute = object.geometry && object.geometry.attributes && object.geometry.attributes.position; + if (!base || !attribute) return; + for (let i = 0; i < base.length; i += 3) { + attribute.array[i] = base[i]; + attribute.array[i + 1] = Math.max(0.02, base[i + 1] * factor); + attribute.array[i + 2] = base[i + 2]; + } + attribute.needsUpdate = true; + object.geometry.computeBoundingSphere(); + if (object.geometry.computeVertexNormals) object.geometry.computeVertexNormals(); + } + function addCanvasLabel(scene, THREE, text, x, y, z, scale) { const labelCanvas = document.createElement("canvas"); labelCanvas.width = 512; @@ -136,6 +325,11 @@ return (r << 16) + (g << 8) + b; } + function colorForSeries(index) { + const colors = [0x2f6f9f, 0xc45a42, 0x6b8f3a, 0x8b6bb1, 0xd09b2c, 0x4d908e, 0x9d4edd, 0x577590]; + return colors[index % colors.length]; + } + function attachInteraction(canvas, state) { canvas.onpointerdown = event => { event.preventDefault(); @@ -267,9 +461,9 @@ }, updateFactor: function (canvas, factor) { const state = stateByCanvas.get(canvas); - if (!state || !state.bars) return; + if (!state || !state.scalables) return; state.factor = normalizeFactor(factor); - state.bars.forEach(bar => applyBarFactor(bar, state.factor)); + applyFactorToScalables(state.scalables, state.factor); renderState(state, canvas); }, resize: resizeAndRender,