mirror of
https://github.com/gosticks/partition-filter-visualization.git
synced 2026-08-12 04:40:23 +00:00
feat: split up graph rendering into classes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user