ImAxes Simplified
This is a simplified example of how to achieve the basic embodied interactions presentend in ImAxes by Cordeil et al. For now it only handles histograms and parallel coordinates.
js
// SPDX-License-Identifier: Apache-2.0
// Copyright : J.P. Morgan Chase & Co.
import { Vector3, Scene, HemisphericLight, ArcRotateCamera, Axis, Color3, SixDofDragBehavior, PhysicsPrestepType, HavokPlugin, PhysicsAggregate, PhysicsShapeType , StandardMaterial } from '@babylonjs/core';
import * as anu from '@jpmorganchase/anu';
import * as d3 from 'd3';
import cars from './data/cars.json' assert {type: 'json'};
import HavokPhysics from "@babylonjs/havok";
export async function imaxes(babylonEngine){
const havokInstance = await HavokPhysics();
const havokPlugin = new HavokPlugin(true, havokInstance);
const scene = new Scene(babylonEngine);
scene.enablePhysics(new Vector3(0,0,0), havokPlugin);
const light = new HemisphericLight('light1', new Vector3(0, 10, -10), scene)
const camera = new ArcRotateCamera("Camera", -(Math.PI / 2), Math.PI / 3, 4, new Vector3(0, 0.5, 0), scene);
camera.wheelPrecision = 20;
camera.minZ = 0;
camera.attachControl(true);
const key_types = {
"Name": "string",
"Miles_per_Gallon": "number",
"Cylinders": "number",
"Displacement": "number",
"Horsepower": "number",
"Weight_in_lbs": "number",
"Acceleration": "number",
"Year": "date",
"Origin": "string"
}
let carsColumns = []
Object.keys(key_types).forEach(k => {
carsColumns.push(cars.map(d => d[k]))
})
const axes_height = 1;
const axes_diameter = 0.1;
let colorScale = d3.scaleOrdinal(anu.ordinalChromatic('d310').toColor4())
let originList = cars.map(d => d.Origin)
let imAxes = anu.bind("cylinder", {diameter: axes_diameter, height: axes_height}, carsColumns)
.name((d,n,i) => Object.keys(key_types)[i])
//.behavior((d,n,i) => new PointerDragBehavior({ dragPlaneNormal: Axis.X}))
.behavior((d,n,i) => {
let behavior = new SixDofDragBehavior()
behavior.allowMultiPointer = true;
behavior.faceCameraOnDragStart = true;
behavior.rotateDraggedObject = false;
behavior.rotateWithMotionController = false;
return behavior
})
let colliderMat = new StandardMaterial('colliderMat')
colliderMat.alpha = 0.1
let barMaterial = new StandardMaterial("barMaterial")
barMaterial.diffuseColor = Color3.Teal()
const FILTER_GROUP_TRIGGER = 2;
imAxes.positionX((d,n,i) => (i * 1.5) - 5).positionY(0.75)
let labels = anu.bind("planeText", {text: (d) => d.replaceAll("_", " "), size: 0.25}, Object.keys(key_types))
.run((d,n,i) => n.parent = imAxes.selected[i])
.positionY((axes_height / 2) + 0.1)
let parallel_triggers = imAxes.bind("sphere", {diameter: axes_height + 0.25})
.material(colliderMat)
.prop("isPickable", false)
.name((d,n, i) => Object.keys(key_types)[i] + "_collider")
.run((d,n) => {
var colliderAggregate = new PhysicsAggregate(n, PhysicsShapeType.BOX, { mass: Infinity}, scene);
colliderAggregate.body.setCollisionCallbackEnabled(true);
colliderAggregate.body.disablePreStep = false;
colliderAggregate.body.setPrestepType(PhysicsPrestepType.TELEPORT);
colliderAggregate.shape.filterMembershipMask = FILTER_GROUP_TRIGGER;
colliderAggregate.shape.filterColliderMask = FILTER_GROUP_TRIGGER;
colliderAggregate.shape.isTrigger = true;
})
let parallel_charts = [];
const trigger_observer = havokPlugin.onTriggerCollisionObservable.add((collisionEvent) => {
let name1 = collisionEvent.collider.transformNode.name.replace("_collider", "");
let name2 = collisionEvent.collidedAgainst.transformNode.name.replace("_collider", "");
if (collisionEvent.type === "TRIGGER_EXITED") {
disposePara(name1, name2)
} else if (collisionEvent.collider.shape.filterMembershipMask === FILTER_GROUP_TRIGGER){
if (collisionEvent.type === "TRIGGER_ENTERED") {
let axis1 = collisionEvent.collider.transformNode.parent
let axis2 = collisionEvent.collidedAgainst.transformNode.parent
let orientation = checkOrientation(axis1, axis2)
if (scene.getMeshByName(name1 + name2 + "_para") === null && orientation === "parallel") createParallelCoords(name1,name2)
}
}
})
function disposePara(name1, name2){
name1 = name1.replace("_collider", "")
name2 = name2.replace("_collider", "")
anu.selectName(name1 + name2 + "_para", scene).dispose()
Array.from([name1, name2, name1 + name2]).forEach(str => {
const index = parallel_charts.indexOf(str);
if (index !== -1) parallel_charts.splice(index, 1);
});
if (!(parallel_charts.includes(name1))) {
anu.selectName(name1 + "_hist", scene).run((d,n,i) => n.setEnabled(true))
anu.selectName(name1 + "_para_axis", scene).dispose()
}
if (!(parallel_charts.includes(name2))){
anu.selectName(name2 + "_hist", scene).run((d,n,i) => n.setEnabled(true))
anu.selectName(name2 + "_para_axis", scene).dispose()
}
}
Object.keys(key_types).forEach(k => {
createBarChart(k)
})
function createBarChart(axesName) {
axesName = axesName.replace("_collider", "")
let axis = anu.selectName(axesName, scene)
let data = axis.selected[0].metadata.data;
let parent = axis.bind("container")
.name(axesName + "_hist")
let bandScale
let linearScale
let bins
if (key_types[axesName] === "number") {
bins = d3.bin().value((d) => d)(data)
bandScale = d3.scaleBand([-axes_height / 2, axes_height / 2]).domain([...Array(bins.length).keys()]).paddingInner(1).paddingOuter(0.5)
linearScale = d3.scaleLinear().domain(d3.extent(bins.map(d => d.length - 2))).range([axes_diameter / 2, 1])
let size = (axes_height / bins.length) * 0.5
parent.bind('box', {depth: 0.05, height: size }, bins)
.material(barMaterial)
.scalingX((d) => linearScale(d.length))
.positionY((d,n,i) => bandScale(i))
.positionX((d, n) => (linearScale(d.length) + (axes_diameter / 2)) / 2)
var label_format = {y: (d) => bins[d].x0 + "-" + bins[d].x1}
} else {
bins = d3.groups(cars, d => d[axesName])
bandScale = d3.scaleBand([-axes_height / 2, axes_height / 2]).domain([...Array(bins.length).keys()]).paddingInner(1).paddingOuter(0.5)
linearScale = d3.scaleLinear().domain(d3.extent(bins.map(d => d[1].length))).range([axes_diameter / 2, 1])
let size = (axes_height / bins.length) * 0.5
parent.bind('box', {depth: 0.05, height: size}, bins)
.material(barMaterial)
.scalingX((d) => linearScale(d[1].length))
.positionY((d,n,i) => bandScale(i))
.positionX((d, n) => (linearScale(d[1].length) + (axes_diameter / 2)) / 2)
var label_format = {}
}
let axisConfig = new anu.AxesConfig({x: linearScale, y: bandScale})
axisConfig.parent = parent;
axisConfig.background = false;
axisConfig.grid = false;
axisConfig.domainMaterialOptions = {width: 0.01};
axisConfig.labelTicks = {y: d3.extent(bandScale.domain())}
axisConfig.labelFormat = label_format
axisConfig.labelOptions = {y: {size: 0.15}, x: {size: 0.1}}
axisConfig.labelMargin = {x: 0.05}
anu.createAxes(axesName + "_hist_axis", scene, axisConfig)
}
let calcPoints = (data, mesh1, mesh2, scale1, scale2) => {
let points = []
let position1 = mesh1.getAbsolutePosition()
let position2 = mesh2.getAbsolutePosition()
data[0].forEach((v,i) => {
let start = new Vector3(position1.x, position1.y + scale1(v), position1.z)
let end = new Vector3(position2.x, position2.y + scale2(data[1][i]), position2.z)
points.push([end, start])
})
return points
}
function createParallelCoords(axesName1, axesName2) {
axesName1 = axesName1.replace("_collider", "")
axesName2 = axesName2.replace("_collider", "")
parallel_charts.push(...[axesName1, axesName2, axesName1 + axesName2])
let axis1 = anu.selectName(axesName1, scene)
let axis2 = anu.selectName(axesName2, scene)
axis1.selectName(axesName1 + "_hist").run((d,n,i) => n.setEnabled(false))
axis2.selectName(axesName2 + "_hist").run((d,n,i) => n.setEnabled(false))
let mesh1 = axis1.selected[0]
let mesh2 = axis2.selected[0]
let data1 = axis1.get("metadata.data")[0];
let data2 = axis2.get("metadata.data")[0];
let range = [-axes_height / 2, axes_height / 2];
let scale1 = (typeof data1[0] === "string") ? d3.scalePoint().range(range).domain([...new Set(data1)]) : d3.scaleLinear().range(range).domain(d3.extent(data1))
let scale2 = (typeof data2[0] === "string") ? d3.scalePoint().range(range).domain([...new Set(data2)]) : d3.scaleLinear().range(range).domain(d3.extent(data2))
let parent = anu.bind("container")
.name(axesName1 + axesName2 + "_para")
let lineSystem = parent.bind("lineSystem", {lines: (d) => calcPoints(d, mesh1, mesh2, scale1, scale2), colors: () => originList.map(v => [colorScale(v), colorScale(v)]), updatable: true}, [[data1,data2]])
.prop("isPickable", false)
let lineSystemMesh = lineSystem.selected[0]
Array.from([mesh1, mesh2]).forEach((n) => {
let observer = n.behaviors[0].onDragObservable.add((event) => {
if (scene.getMeshByName(axesName1 + axesName2 + "_para") !== null) {
try {
anu.create("lineSystem", "lineSystem", {lines: (d) => calcPoints(d, mesh1, mesh2, scale1, scale2), updatable: true, instance: lineSystemMesh}, [data1,data2])
} catch {
console.warn("unknown")
}
} else {
observer.remove()
}
});
})
let axisConfig1 = new anu.AxesConfig({y: scale1})
axisConfig1.parent = axis1;
axisConfig1.background = false;
axisConfig1.grid = false;
axisConfig1.domainMaterialOptions = {width: 0.01};
axisConfig1.labelTicks = {y: (scale1.domain().length > 10) ? evenDistributedSlice(scale1.domain(), 10) : undefined}
// axisConfig.labelFormat = label_format
axisConfig1.labelOptions = {y: {size: 0.15, align: "center"}}
axisConfig1.labelProperties = {y: {"position.x": 0, "position.z": -axes_diameter / 2}}
anu.createAxes(axesName1 + "_para_axis", scene, axisConfig1)
let axisConfig2 = new anu.AxesConfig({y: scale2})
axisConfig2.parent = axis2;
axisConfig2.background = false;
axisConfig2.grid = false;
axisConfig2.domainMaterialOptions = {width: 0.01};
axisConfig2.labelTicks = {y: (scale2.domain().length > 10) ? evenDistributedSlice(scale2.domain(), 10) : undefined}
axisConfig2.labelOptions = {y: {size: 0.15, align: "center"}}
axisConfig2.labelProperties = {y: {"position.x": 0, "position.z": -axes_diameter / 2}}
anu.createAxes(axesName2 + "_para_axis", scene, axisConfig2)
}
return scene;
}
function evenDistributedSlice(arr, n) {
if (n <= 0) return [];
const step = (arr.length - 1) / Math.max(n - 1, 1);
return Array.from({ length: n }, (_, i) => arr[Math.round(i * step)]);
}
function checkOrientation(mesh1, mesh2) {
// Get the world matrix of each mesh
var worldMatrix1 = mesh1.getWorldMatrix();
var worldMatrix2 = mesh2.getWorldMatrix();
// Get the up vectors of each mesh using the world matrix
var up1 = Vector3.TransformNormal(Axis.Y, worldMatrix1);
var up2 = Vector3.TransformNormal(Axis.Y, worldMatrix2);
// Normalize the vectors
up1.normalize();
up2.normalize();
// Define a small epsilon for floating-point comparison
var epsilon = 0.01;
// Function to check if two vectors are parallel
function areVectorsParallel(v1, v2, epsilon) {
var dotProduct = Vector3.Dot(v1, v2);
return Math.abs(dotProduct - 1) < epsilon || Math.abs(dotProduct + 1) < epsilon;
}
// Function to check if two vectors are perpendicular
function areVectorsPerpendicular(v1, v2, epsilon) {
var dotProduct = Vector3.Dot(v1, v2);
return Math.abs(dotProduct) < epsilon;
}
// Check parallelism and perpendicularity for the chosen axes
var parallel = areVectorsParallel(up1, up2, epsilon)
var perpendicular = areVectorsPerpendicular(up1, up2, epsilon);
if (parallel) {
return "parallel"
} else if (perpendicular) {
return "perpendicular"
} else {
return undefined
}
}