553 lines
22 KiB
JavaScript
553 lines
22 KiB
JavaScript
(function () {
|
|
const stateByCanvas = new WeakMap();
|
|
|
|
function normalizeRows(rows) {
|
|
return Array.isArray(rows) ? rows.filter(row => row && Number.isFinite(Number(row.value))) : [];
|
|
}
|
|
|
|
function buildAxes(rows) {
|
|
const countries = [...new Set(rows.map(row => String(row.country || "-")))].sort();
|
|
const years = [...new Set(rows.map(row => Number(row.year || 0)))].sort((a, b) => a - b);
|
|
const maxValue = rows.reduce((max, row) => Math.max(max, Math.abs(Number(row.value || 0))), 0) || 1;
|
|
return { countries, years, maxValue };
|
|
}
|
|
|
|
function createThreeScene(canvas, rows, options) {
|
|
const THREE = window.THREE;
|
|
const factor = normalizeFactor(options && options.scenarioFactor);
|
|
const chartType = normalizeChartType(options && options.chartType);
|
|
const labelScale = normalizeLabelScale(options && options.labelScale);
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
renderer.setClearColor(0xf7f9fb, 1);
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
|
|
camera.position.set(18, 15, 22);
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
const root = new THREE.Group();
|
|
scene.add(root);
|
|
scene.add(new THREE.AmbientLight(0xffffff, 0.72));
|
|
const light = new THREE.DirectionalLight(0xffffff, 0.75);
|
|
light.position.set(8, 18, 10);
|
|
scene.add(light);
|
|
|
|
const axes = buildAxes(rows);
|
|
const xStep = axes.countries.length > 1 ? 16 / (axes.countries.length - 1) : 0;
|
|
const zStep = axes.years.length > 1 ? 12 / (axes.years.length - 1) : 0;
|
|
const xStart = -8;
|
|
const zStart = -6;
|
|
|
|
const gridMaterial = new THREE.LineBasicMaterial({ color: 0xb7c1cc, transparent: true, opacity: 0.55 });
|
|
const gridPoints = [];
|
|
for (let i = 0; i <= axes.countries.length; i++) {
|
|
const x = xStart + (i - 0.5) * (xStep || 2);
|
|
gridPoints.push(new THREE.Vector3(x, 0, zStart - 1), new THREE.Vector3(x, 0, zStart + Math.max(1, axes.years.length - 1) * (zStep || 2) + 1));
|
|
}
|
|
for (let i = 0; i <= axes.years.length; i++) {
|
|
const z = zStart + (i - 0.5) * (zStep || 2);
|
|
gridPoints.push(new THREE.Vector3(xStart - 1, 0, z), new THREE.Vector3(xStart + Math.max(1, axes.countries.length - 1) * (xStep || 2) + 1, 0, z));
|
|
}
|
|
root.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(gridPoints), gridMaterial));
|
|
addAxisGuides(scene, THREE, layoutFromAxes(axes, xStep, zStep, xStart, zStart), options || {}, chartType, labelScale);
|
|
|
|
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, labelScale);
|
|
} else {
|
|
createBarChart(THREE, root, rows, layout, scalables);
|
|
}
|
|
applyFactorToScalables(scalables, factor);
|
|
|
|
addCanvasLabel(scene, THREE, options.title || "", -8.8, 9.2, -7.8, 1.05 * labelScale);
|
|
axes.countries.forEach((country, index) => addCanvasLabel(scene, THREE, country, xStart + index * (xStep || 2), -0.15, zStart - 1.3, 0.58 * labelScale));
|
|
axes.years.forEach((year, index) => addCanvasLabel(scene, THREE, String(year), xStart - 1.6, -0.15, zStart + index * (zStep || 2), 0.58 * labelScale));
|
|
|
|
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 : 23,
|
|
targetX: previous ? previous.targetX : 0,
|
|
targetY: previous ? previous.targetY : 2.8,
|
|
targetZ: previous ? previous.targetZ : 0,
|
|
factor,
|
|
scalables,
|
|
dragging: false,
|
|
dragMode: "rotate",
|
|
lastX: 0,
|
|
lastY: 0
|
|
};
|
|
attachInteraction(canvas, state);
|
|
stateByCanvas.set(canvas, state);
|
|
resizeAndRender(canvas);
|
|
}
|
|
|
|
function layoutFromAxes(axes, xStep, zStep, xStart, zStart) {
|
|
return {
|
|
axes,
|
|
xStep,
|
|
zStep,
|
|
xStart,
|
|
zStart,
|
|
xEnd: xStart + Math.max(1, axes.countries.length - 1) * (xStep || 2),
|
|
zEnd: zStart + Math.max(1, axes.years.length - 1) * (zStep || 2)
|
|
};
|
|
}
|
|
|
|
function normalizeFactor(value) {
|
|
const factor = Number(value);
|
|
if (!Number.isFinite(factor)) return 1;
|
|
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 normalizeLabelScale(value) {
|
|
const scale = Number(value);
|
|
if (!Number.isFinite(scale)) return 1.4;
|
|
return Math.max(0.8, Math.min(2.5, scale));
|
|
}
|
|
|
|
function addAxisGuides(scene, THREE, layout, options, chartType, labelScale) {
|
|
if (chartType === "pie") {
|
|
addCanvasLabel(scene, THREE, options.pieAxis || "Pie: country shares", -8.4, 8.2, -7.6, 0.85 * labelScale);
|
|
addCanvasLabel(scene, THREE, options.yAxis || "Y: value / indicator", 6.2, 1.4, 6.8, 0.62 * labelScale);
|
|
return;
|
|
}
|
|
|
|
const xEnd = layout.xEnd + 1.2;
|
|
const zEnd = layout.zEnd + 1.2;
|
|
const axisYOffset = 0.04;
|
|
addAxisLine(scene, THREE, new THREE.Vector3(layout.xStart - 1.2, axisYOffset, layout.zStart - 1.0), new THREE.Vector3(xEnd, axisYOffset, layout.zStart - 1.0), 0x2869a6);
|
|
addAxisLine(scene, THREE, new THREE.Vector3(layout.xStart - 1.1, axisYOffset, layout.zStart - 1.0), new THREE.Vector3(layout.xStart - 1.1, axisYOffset, zEnd), 0x7a8f2a);
|
|
addAxisLine(scene, THREE, new THREE.Vector3(layout.xStart - 1.1, 0, layout.zStart - 1.0), new THREE.Vector3(layout.xStart - 1.1, 8.8, layout.zStart - 1.0), 0xb84f3a);
|
|
|
|
addCanvasLabel(scene, THREE, options.xAxis || "X: country", xEnd, 0.45, layout.zStart - 1.2, 0.66 * labelScale);
|
|
addCanvasLabel(scene, THREE, options.zAxis || "Z: year / time", layout.xStart - 1.4, 0.45, zEnd, 0.66 * labelScale);
|
|
addCanvasLabel(scene, THREE, options.yAxis || "Y: value / indicator", layout.xStart - 1.6, 9.3, layout.zStart - 1.0, 0.66 * labelScale);
|
|
}
|
|
|
|
function addAxisLine(scene, THREE, from, to, color) {
|
|
const material = new THREE.LineBasicMaterial({ color, linewidth: 3 });
|
|
const geometry = new THREE.BufferGeometry().setFromPoints([from, to]);
|
|
scene.add(new THREE.Line(geometry, material));
|
|
|
|
const direction = new THREE.Vector3().subVectors(to, from).normalize();
|
|
const cone = new THREE.Mesh(
|
|
new THREE.ConeGeometry(0.22, 0.55, 18),
|
|
new THREE.MeshBasicMaterial({ color }));
|
|
cone.position.copy(to);
|
|
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction);
|
|
scene.add(cone);
|
|
}
|
|
|
|
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, labelScale) {
|
|
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 * labelScale);
|
|
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;
|
|
labelCanvas.height = 128;
|
|
const ctx = labelCanvas.getContext("2d");
|
|
ctx.clearRect(0, 0, labelCanvas.width, labelCanvas.height);
|
|
ctx.fillStyle = "#243241";
|
|
ctx.font = "600 44px Open Sans, Arial, sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(String(text || "-"), 256, 64, 480);
|
|
const texture = new THREE.CanvasTexture(labelCanvas);
|
|
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
|
const sprite = new THREE.Sprite(material);
|
|
sprite.position.set(x, y, z);
|
|
sprite.scale.set(3.5 * scale, 0.85 * scale, 1);
|
|
scene.add(sprite);
|
|
}
|
|
|
|
function colorForValue(t) {
|
|
const clamped = Math.max(0, Math.min(1, t));
|
|
const r = Math.round(45 + clamped * 178);
|
|
const g = Math.round(105 + clamped * 78);
|
|
const b = Math.round(155 - clamped * 88);
|
|
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();
|
|
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);
|
|
};
|
|
canvas.onpointermove = event => {
|
|
if (!state.dragging) return;
|
|
const dx = event.clientX - state.lastX;
|
|
const dy = event.clientY - state.lastY;
|
|
state.lastX = event.clientX;
|
|
state.lastY = event.clientY;
|
|
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();
|
|
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 size = resolveCanvasSize(canvas);
|
|
const width = size.width;
|
|
const height = size.height;
|
|
state.camera.aspect = width / height;
|
|
state.camera.updateProjectionMatrix();
|
|
const horizontal = Math.cos(state.angleX) * state.distance;
|
|
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);
|
|
}
|
|
|
|
function resolveCanvasSize(canvas) {
|
|
const parent = canvas.parentElement;
|
|
const parentRect = parent ? parent.getBoundingClientRect() : null;
|
|
const canvasRect = canvas.getBoundingClientRect();
|
|
const width = Math.max(
|
|
640,
|
|
Math.floor(parentRect && parentRect.width > 320 ? parentRect.width : canvasRect.width || canvas.clientWidth || 900));
|
|
const height = Math.max(
|
|
520,
|
|
Math.floor(parentRect && parentRect.height > 240 ? parentRect.height : canvasRect.height || canvas.clientHeight || 680));
|
|
canvas.style.width = "100%";
|
|
canvas.style.height = "100%";
|
|
return { width, height };
|
|
}
|
|
|
|
function renderFallback(canvas, rows, options) {
|
|
const ctx = canvas.getContext("2d");
|
|
const rect = resolveCanvasSize(canvas);
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
|
|
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
const width = rect.width;
|
|
const height = rect.height;
|
|
ctx.fillStyle = "#f7f9fb";
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.fillStyle = "#243241";
|
|
ctx.font = "600 16px Open Sans, Arial, sans-serif";
|
|
ctx.fillText(options.title || "3D data analysis", 18, 28);
|
|
|
|
const axes = buildAxes(rows);
|
|
const barWidth = Math.max(12, Math.min(42, (width - 80) / Math.max(1, rows.length) * 0.72));
|
|
rows.forEach((row, index) => {
|
|
const value = Math.abs(Number(row.value || 0));
|
|
const barHeight = value / axes.maxValue * (height - 110);
|
|
const x = 44 + index * ((width - 90) / Math.max(1, rows.length));
|
|
const y = height - 42 - barHeight;
|
|
ctx.fillStyle = `hsl(${205 - (value / axes.maxValue) * 115}, 58%, 45%)`;
|
|
ctx.fillRect(x, y, barWidth, barHeight);
|
|
});
|
|
ctx.fillStyle = "#52606d";
|
|
ctx.font = "12px Open Sans, Arial, sans-serif";
|
|
ctx.fillText("Fallback canvas renderer: Three.js could not be loaded.", 18, height - 16);
|
|
}
|
|
|
|
function resizeAndRender(canvas) {
|
|
const state = stateByCanvas.get(canvas);
|
|
if (state) renderState(state, canvas);
|
|
}
|
|
|
|
window.trafagFinance3d = {
|
|
render: function (canvas, rows, options) {
|
|
if (!canvas || typeof canvas.addEventListener !== "function") return;
|
|
const normalizedRows = normalizeRows(rows);
|
|
if (normalizedRows.length === 0) return;
|
|
const existing = stateByCanvas.get(canvas);
|
|
if (existing && existing.renderer && existing.renderer.dispose) {
|
|
existing.renderer.dispose();
|
|
}
|
|
if (window.THREE && window.THREE.WebGLRenderer) {
|
|
createThreeScene(canvas, normalizedRows, options || {});
|
|
} else {
|
|
renderFallback(canvas, normalizedRows, options || {});
|
|
}
|
|
},
|
|
updateFactor: function (canvas, factor) {
|
|
const state = stateByCanvas.get(canvas);
|
|
if (!state || !state.scalables) return;
|
|
state.factor = normalizeFactor(factor);
|
|
applyFactorToScalables(state.scalables, state.factor);
|
|
renderState(state, canvas);
|
|
},
|
|
resize: resizeAndRender,
|
|
pixelProbe: function (canvas) {
|
|
const ctx = canvas && canvas.getContext ? canvas.getContext("2d", { willReadFrequently: true }) : null;
|
|
if (!ctx) return -1;
|
|
const data = ctx.getImageData(0, 0, Math.min(32, canvas.width), Math.min(32, canvas.height)).data;
|
|
let sum = 0;
|
|
for (let i = 0; i < data.length; i += 4) sum += data[i] + data[i + 1] + data[i + 2];
|
|
return sum;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("resize", () => {
|
|
document.querySelectorAll(".finance-3d-canvas").forEach(canvas => resizeAndRender(canvas));
|
|
});
|
|
})();
|