@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,