feat: split up graph rendering into classes

This commit is contained in:
Wlad Meixner
2023-07-19 11:49:50 +02:00
parent b26f7e0d20
commit 32e25d32cd
3 changed files with 335 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
import * as THREE from 'three';
import { MeshLine, MeshLineMaterial } from 'three.meshline';
export interface AxisLabelOptions {
color: THREE.Color;
font: string;
fontSize: number;
fontLineHeight: number;
text: string;
}
export interface AxisOptions {
lineWidth: number;
lineColor: THREE.Color;
label: AxisLabelOptions;
}
export interface AxisRendererOptions {
size: THREE.Vector3;
labelScale: number;
origin: THREE.Vector3;
x: AxisOptions;
y: AxisOptions;
z: AxisOptions;
}
const defaultAxisLabelOptions = {
color: new THREE.Color(0xcccccc),
font: 'Arial',
fontSize: 100,
fontLineHeight: 1.2,
text: ''
};
const defaultAxisOptions = {
lineWidth: 5,
lineColor: new THREE.Color(0xeeeeee),
label: defaultAxisLabelOptions
};
const defaultAxisRendererOptions: AxisRendererOptions = {
size: new THREE.Vector3(1, 1, 1),
labelScale: 10,
origin: new THREE.Vector3(0, 0, 0),
x: {
...defaultAxisOptions,
label: {
...defaultAxisLabelOptions,
text: 'x'
}
},
y: {
...defaultAxisOptions,
label: {
...defaultAxisLabelOptions,
text: 'y'
}
},
z: {
...defaultAxisOptions,
label: {
...defaultAxisLabelOptions,
text: 'z'
}
}
};
export class AxisRenderer {
private options: AxisRendererOptions;
private group: THREE.Group;
constructor(private scene: THREE.Scene, options: Partial<AxisRendererOptions> = {}) {
this.options = {
...defaultAxisRendererOptions,
...options
};
this.group = new THREE.Group();
this.scene.add(this.group);
// this.render();
}
render(): void {
this.group.children = [];
this.group.add(this.createAxis(this.options.x, new THREE.Vector3(1, 0, 0)));
this.group.add(this.createAxis(this.options.y, new THREE.Vector3(0, 1, 0)));
this.group.add(this.createAxis(this.options.z, new THREE.Vector3(0, 0, 1)));
}
destroy(): void {
this.group.remove();
}
private createAxis = (options: AxisOptions, direction: THREE.Vector3): THREE.Object3D => {
const axis = new THREE.Group();
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
direction
]);
const meshLine = new MeshLine();
meshLine.setGeometry(geometry);
const material = new MeshLineMaterial({
color: options.lineColor,
lineWidth: options.lineWidth
});
const line = new THREE.Mesh(meshLine.geometry, material);
// Scale the line along the direction vector to the desired length
const scaleFactor = direction.clone().multiply(this.options.size.clone());
line.scale.set(scaleFactor.x, scaleFactor.y, scaleFactor.z);
axis.add(line);
const label = new THREE.Sprite(
new THREE.SpriteMaterial({
transparent: true,
map: this.createTextTexture(options.label)
})
);
const labelOffset = direction.clone().multiply(this.options.size.clone().multiplyScalar(0.5));
label.position.set(
labelOffset.x === 0 ? -1 * this.options.labelScale : labelOffset.x,
labelOffset.y === 0 ? -1 * this.options.labelScale : labelOffset.y,
labelOffset.z === 0 ? -1 * this.options.labelScale : labelOffset.z
);
label.scale.set(this.options.labelScale, this.options.labelScale, this.options.labelScale);
axis.add(label);
return axis;
};
private createTextTexture(options: AxisLabelOptions): THREE.Texture | null {
let textCanvas: HTMLCanvasElement | undefined = undefined;
let textContext: CanvasRenderingContext2D | undefined = undefined;
// Create a canvas element
const canvas = document.createElement('canvas');
canvas.width = options.text.length * options.fontSize;
canvas.height = options.fontSize * options.fontLineHeight;
// Get the 2D rendering context of the canvas
const context = canvas.getContext('2d');
if (!context) {
return null;
}
textCanvas = canvas;
textContext = context;
// Set the font properties
textContext.font = `${options.fontSize}px ${options.font}`;
// Set the text color
textContext.fillStyle = options.color.getStyle();
// Set the text alignment and baseline
textContext.textAlign = 'center';
textContext.textBaseline = 'middle';
// Calculate the text position in the center of the canvas
const canvasWidth = textCanvas.width;
const canvasHeight = textCanvas.height;
const textX = canvasWidth / 2;
const textY = canvasHeight / 2;
// Render the text on the canvas
textContext.fillText(options.text, textX, textY);
// Create a texture from the canvas
const texture = new THREE.CanvasTexture(textCanvas);
// TODO: maybe reuse canvas if we update the labels frequently
// Remove the canvas from the DOM
// document.removeChild(textCanvas);
return texture;
}
}
+140
View File
@@ -0,0 +1,140 @@
import type { Vector3 } from 'three';
import * as THREE from 'three';
import { GraphRenderer } from './GraphRenderer';
interface BarData {
data: number[][][];
}
interface BarRendererOptions {
barWidth: number;
barDepth: number;
barGap: number;
}
const defaultBarRendererOptions: BarRendererOptions = {
barWidth: 1,
barDepth: 1,
barGap: 0.1
};
// Define your colors
const color1 = new THREE.Color('#F0F624');
const color2 = new THREE.Color('#C5407D');
const color3 = new THREE.Color('#15078A');
const dataColorizer = (value: number, dataIndex: Vector3, axisProgress: Vector3): THREE.Color => {
const xColor = color1.clone().lerp(color2, axisProgress.x);
return xColor.lerp(color3, axisProgress.y);
};
export class BarRenderer extends GraphRenderer<BarData> {
private barGroup: THREE.Group;
private options: BarRendererOptions;
private size: THREE.Vector3 = new THREE.Vector3(0, 0, 0);
constructor(
public scene: THREE.Scene,
public camera: THREE.Camera,
options: Partial<BarRendererOptions> = {}
) {
super(scene, camera);
this.options = {
...defaultBarRendererOptions,
...options
};
this.barGroup = new THREE.Group();
this.scene.add(this.barGroup);
}
destroy(): void {
this.scene.remove(this.barGroup);
}
setScale(scale: THREE.Vector3): void {
this.size = scale;
this.barGroup.scale.copy(scale);
}
updateWithData(data: BarData) {
// Clear group
this.barGroup.children = [];
// Compute min and max values
let maxBarHeight = -Infinity;
// Compute maxBar height to correctly scale bars
data.data.forEach((z) =>
z.forEach(
(y) =>
(maxBarHeight = Math.max(
y.reduce((a, b) => a + b, 0),
maxBarHeight
))
)
);
// Iterate over input data and create bars
for (let z = 0; z < data.data.length; z++) {
for (let x = 0; x < data.data[z].length; x++) {
let currentBarHeight = 0;
for (let y = 0; y < data.data[z][x].length; y++) {
const value = data.data[z][x][y];
// Sanity check for invalid data
if (value <= 0) {
continue;
}
const color = dataColorizer(
value,
new THREE.Vector3(x, y, z),
new THREE.Vector3(
x / data.data[z][x].length,
y / data.data[z].length,
z / data.data.length
)
);
const barSegmentHeight = value / maxBarHeight;
// Create bar
const bar = this.createBarSegment(
// Scale bar to 0 to 1 range
barSegmentHeight,
this.options.barWidth,
this.options.barDepth,
color
);
// Position bar
bar.position.set(
this.options.barWidth / 2 + x * (this.options.barWidth + this.options.barGap),
currentBarHeight + barSegmentHeight / 2,
this.options.barDepth / 2 + z * (this.options.barWidth + this.options.barGap)
);
currentBarHeight += barSegmentHeight;
// Add bar to group
this.barGroup.add(bar);
}
}
}
}
private createBarSegment(
height: number,
width: number,
depth: number,
color: THREE.Color
): THREE.Mesh {
const geometry = new THREE.BoxGeometry(width, height, depth);
const material = new THREE.MeshPhongMaterial({ color, transparent: true });
const bar = new THREE.Mesh(geometry, material);
return bar;
}
}
+11
View File
@@ -0,0 +1,11 @@
export abstract class GraphRenderer<T> {
constructor(public scene: THREE.Scene, public camera: THREE.Camera) {}
abstract destroy(): void;
/**
* Used to update rendering based on data changes
* @param data
*/
abstract updateWithData(data: T): void;
}