From 32e25d32cd9727aa117ed0fcedc0bc7a57e57475 Mon Sep 17 00:00:00 2001 From: Wlad Meixner Date: Wed, 19 Jul 2023 11:49:50 +0200 Subject: [PATCH] feat: split up graph rendering into classes --- src/lib/rendering/AxisRenderer.ts | 184 +++++++++++++++++++++++++++++ src/lib/rendering/BarRenderer.ts | 140 ++++++++++++++++++++++ src/lib/rendering/GraphRenderer.ts | 11 ++ 3 files changed, 335 insertions(+) create mode 100644 src/lib/rendering/AxisRenderer.ts create mode 100644 src/lib/rendering/BarRenderer.ts create mode 100644 src/lib/rendering/GraphRenderer.ts diff --git a/src/lib/rendering/AxisRenderer.ts b/src/lib/rendering/AxisRenderer.ts new file mode 100644 index 0000000..ce2a432 --- /dev/null +++ b/src/lib/rendering/AxisRenderer.ts @@ -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 = {}) { + 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; + } +} diff --git a/src/lib/rendering/BarRenderer.ts b/src/lib/rendering/BarRenderer.ts new file mode 100644 index 0000000..669675c --- /dev/null +++ b/src/lib/rendering/BarRenderer.ts @@ -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 { + 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 = {} + ) { + 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; + } +} diff --git a/src/lib/rendering/GraphRenderer.ts b/src/lib/rendering/GraphRenderer.ts new file mode 100644 index 0000000..fd6d9fa --- /dev/null +++ b/src/lib/rendering/GraphRenderer.ts @@ -0,0 +1,11 @@ +export abstract class GraphRenderer { + 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; +}