Add finance 3D chart modes

This commit is contained in:
2026-06-04 14:11:11 +02:00
parent 9409174a07
commit fde7f6bc95
2 changed files with 251 additions and 25 deletions
@@ -662,6 +662,14 @@
} }
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" Value="_finance3dChartType" ValueChanged="SetFinance3dChartType" Label="@T("Grafik", "Chart")" Dense>
@foreach (var option in _finance3dChartTypeOptions)
{
<MudSelectItem Value="@option.Key">@T(option.GermanLabel, option.EnglishLabel)</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudText Typo="Typo.caption">@T("Szenario-Faktor / Wechselkurs", "Scenario factor / exchange rate")</MudText> <MudText Typo="Typo.caption">@T("Szenario-Faktor / Wechselkurs", "Scenario factor / exchange rate")</MudText>
<div class="finance-3d-range-row"> <div class="finance-3d-range-row">
@@ -696,10 +704,10 @@
</MudText> </MudText>
} }
</MudItem> </MudItem>
<MudItem xs="12" md="5"> <MudItem xs="12" md="3">
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">
@T("Linke Maustaste drehen, Mausrad zoomen, Shift+Ziehen oder rechte Maustaste verschieben. X-Achse = Land, Z-Achse = Jahr, Hoehe = gewaehlter Indikator.", @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. X axis = country, Z axis = year, height = selected indicator.") "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.")
</MudText> </MudText>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
@@ -1083,6 +1091,13 @@
new(Finance3dIndicators.ExcludedRows, "Ausgeschlossene Zeilen", "Excluded rows"), new(Finance3dIndicators.ExcludedRows, "Ausgeschlossene Zeilen", "Excluded rows"),
new(Finance3dIndicators.Deviation, "Soll/Ist Differenz Filterjahr", "Actual/reference difference filter year") new(Finance3dIndicators.Deviation, "Soll/Ist Differenz Filterjahr", "Actual/reference difference filter year")
]; ];
private readonly List<Finance3dChartTypeOption> _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 string? _selectedFilePath;
private ManagementCockpitResult? _result; private ManagementCockpitResult? _result;
private ManagementCockpitCentralResult? _centralResult; private ManagementCockpitCentralResult? _centralResult;
@@ -1109,6 +1124,7 @@
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 string _finance3dChartType = Finance3dChartTypes.Bar;
private double _finance3dScenarioFactor = 1d; private double _finance3dScenarioFactor = 1d;
private ElementReference _finance3dCanvas; private ElementReference _finance3dCanvas;
private bool _finance3dNeedsRender; private bool _finance3dNeedsRender;
@@ -1291,6 +1307,12 @@
await RenderFinance3dAsync(); await RenderFinance3dAsync();
} }
private async Task SetFinance3dChartType(string value)
{
_finance3dChartType = string.IsNullOrWhiteSpace(value) ? Finance3dChartTypes.Bar : value;
await RenderFinance3dAsync();
}
private async Task SetFinance3dScenarioFactor(ChangeEventArgs args) private async Task SetFinance3dScenarioFactor(ChangeEventArgs args)
{ {
if (double.TryParse(Convert.ToString(args.Value, CultureInfo.InvariantCulture), NumberStyles.Number, CultureInfo.InvariantCulture, out var value)) if (double.TryParse(Convert.ToString(args.Value, CultureInfo.InvariantCulture), NumberStyles.Number, CultureInfo.InvariantCulture, out var value))
@@ -1322,6 +1344,7 @@
{ {
indicator = _finance3dIndicator, indicator = _finance3dIndicator,
title = ResolveFinance3dIndicatorLabel(_finance3dIndicator), title = ResolveFinance3dIndicatorLabel(_finance3dIndicator),
chartType = _finance3dChartType,
scenarioFactor = Finance3dScenarioAffectsValue ? _finance3dScenarioFactor : 1d scenarioFactor = Finance3dScenarioAffectsValue ? _finance3dScenarioFactor : 1d
}); });
} }
@@ -1718,8 +1741,17 @@
public const string Deviation = "deviation"; 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 ProductFinanceGroupingOption(string Key, string GermanLabel, string EnglishLabel);
private sealed record Finance3dIndicatorOption(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( private sealed record ProductFinanceGroupKey(
string ProductDivisionCode, string ProductDivisionCode,
+216 -22
View File
@@ -15,6 +15,7 @@
function createThreeScene(canvas, rows, options) { function createThreeScene(canvas, rows, options) {
const THREE = window.THREE; const THREE = window.THREE;
const factor = normalizeFactor(options && options.scenarioFactor); const factor = normalizeFactor(options && options.scenarioFactor);
const chartType = normalizeChartType(options && options.chartType);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0xf7f9fb, 1); renderer.setClearColor(0xf7f9fb, 1);
@@ -49,25 +50,18 @@
} }
root.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(gridPoints), gridMaterial)); root.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(gridPoints), gridMaterial));
const barGeometry = new THREE.BoxGeometry(0.68, 1, 0.68); const scalables = [];
const bars = []; const layout = { axes, xStep, zStep, xStart, zStart };
rows.forEach(row => { if (chartType === "line") {
const countryIndex = Math.max(0, axes.countries.indexOf(String(row.country || "-"))); createLineChart(THREE, root, rows, layout, scalables);
const yearIndex = Math.max(0, axes.years.indexOf(Number(row.year || 0))); } else if (chartType === "surface") {
const rawValue = Math.abs(Number(row.value || 0)); createSurfaceChart(THREE, root, rows, layout, scalables);
const height = Math.max(0.08, rawValue / axes.maxValue * 8); } else if (chartType === "pie") {
const material = new THREE.MeshStandardMaterial({ createPieChart(THREE, root, rows, layout, scalables);
color: colorForValue(rawValue / axes.maxValue), } else {
roughness: 0.58, createBarChart(THREE, root, rows, layout, scalables);
metalness: 0.05 }
}); applyFactorToScalables(scalables, factor);
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);
});
addCanvasLabel(scene, THREE, options.title || "", -8.8, 9.2, -7.8, 1.05); 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.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, targetY: previous ? previous.targetY : 2.8,
targetZ: previous ? previous.targetZ : 0, targetZ: previous ? previous.targetZ : 0,
factor, factor,
bars, scalables,
dragging: false, dragging: false,
dragMode: "rotate", dragMode: "rotate",
lastX: 0, lastX: 0,
@@ -103,12 +97,207 @@
return Math.max(0.5, Math.min(1.5, factor)); 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) { function applyBarFactor(bar, factor) {
const height = Math.max(0.02, Number(bar.userData.baseHeight || 0.08) * factor); const height = Math.max(0.02, Number(bar.userData.baseHeight || 0.08) * factor);
bar.scale.y = height; bar.scale.y = height;
bar.position.y = height / 2; 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) { function addCanvasLabel(scene, THREE, text, x, y, z, scale) {
const labelCanvas = document.createElement("canvas"); const labelCanvas = document.createElement("canvas");
labelCanvas.width = 512; labelCanvas.width = 512;
@@ -136,6 +325,11 @@
return (r << 16) + (g << 8) + b; 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) { function attachInteraction(canvas, state) {
canvas.onpointerdown = event => { canvas.onpointerdown = event => {
event.preventDefault(); event.preventDefault();
@@ -267,9 +461,9 @@
}, },
updateFactor: function (canvas, factor) { updateFactor: function (canvas, factor) {
const state = stateByCanvas.get(canvas); const state = stateByCanvas.get(canvas);
if (!state || !state.bars) return; if (!state || !state.scalables) return;
state.factor = normalizeFactor(factor); state.factor = normalizeFactor(factor);
state.bars.forEach(bar => applyBarFactor(bar, state.factor)); applyFactorToScalables(state.scalables, state.factor);
renderState(state, canvas); renderState(state, canvas);
}, },
resize: resizeAndRender, resize: resizeAndRender,