Add finance 3D chart modes
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user