So, how do we do it... I'll walk you through a basic skeliton custom visual in PowerBI using THREE.JS. We are just going to create a spinning box in a PowerBI Visual, future blog posts will cover manipulating a scene based on data and interactivity.
First, if your not setup for PowerBI Visual development, you need the tooling installed. You will need to install Node and PowerBI-Visual-Tools. Lastly, I also recomend visual studio code. More information can be found here:
https://docs.microsoft.com/en-us/power-bi/developer/visuals/custom-visual-develop-tutorial
The first thing we need is a PowerBI Visual project. So run:
PBIVIZ new MyFirstThreeJSViz
This will create a new folder and load all the necessary and default code, config and librarys for custom visual development.
Next, we need to install THREE.JS.. So inside the new MyFirstThreeJSViz run the following:
npm i three
That's the visual created and THREE.JS library installed, now we just need to code something. I usually use VS Code as an IDE, but feel free to use whatever tool you prefer. Bellow if a screenshot of the shell following the above instructions
I'll try and walk you through the code block by block, but if you get lost, the full code is at the bottom.
First, we need to import the library into our code. The primary visual file we are interested in is visual.ts which can be found in the src folder. Add the bellow import at the top of the file:
import THREE.js
Next, we should define some objects which we will need in our visual. In THREE.JS, you need four basic objects to sucessfully render a scene. You need a Scene object, this will contain everything which makes up the visual. Next, a Camera. The camera controls the viewport into the visual and can be moved to create an animated tour. Next, a Light. without a light in our scene, we would not be able to see anything. Finally, a renderer, this is the object which controls the rendering draw calls and produces the frame.
private scene: THREE.Scene = new THREE.Scene();
private camera: THREE.PerspectiveCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 800);
private light: THREE.PointLight = new THREE.PointLight();
private renderer: THREE.WebGLRenderer = new THREE.WebGLRenderer({ antialias: true });
On top of these four basic objects, I often also create the following because I use them very frequantly. A Clock, raycaster, font, modelloadmanager. Also, it's not uncommon for me to use more than one light. For this project, we only need the Clock.
private clock: THREE.Clock = new THREE.Clock();
I'm creating the objects inline with there definition, but they need some additional setup. We need to add to our constructor the logic to connect everything up and start the animation loop. We do this inside the "if (document) {". First, lets setup our necessary objects (lights, camera, Action!!)
// Lights
this.light.position.set(0,0.8,2);
this.scene.add(this.light);
// Camera
this.camera.position.set(0,0.5,2);
this.camera.lookAt(0,0,0);
this.scene.add(this.camera);
// Renderer
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setClearColor(new THREE.Color(0xFFFFFF));
this.target.appendChild(this.renderer.domElement);
Next, we need to handel resize events. Add an event listener for resize (the handler function is below..)
// Events
window.addEventListener("resize",this.onResize.bind(this));
Our visual/world would be very empty without anything to see/draw. Lets add a Red cube to the scene.
// Draw Something
let boxGeometry = new THREE.BoxBufferGeometry(1,1,1);
let boxMaterial = new THREE.MeshPhongMaterial({ color: new THREE.Color(0xff0000) });
let box = new THREE.Mesh(boxGeometry,boxMaterial);
box.name = 'theBox';
this.scene.add(box);
The only thing remaining that we need in our consturator is to start the animation loop.
this.animate();
We are almost there, we just need to code the onResize event handler function and the animate function. First, the onResize function. Not much needed to know about this, it's very rare I have to tweak it. You can just use it as is.
public onResize() {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
}
For the animate function, we first setup some timeing variables which can be useful for animation/tween timing. Then, we rotate the box using the animation delta time from the THREE.JS Clock. Next is most important, we render the scene. Finally, we request the next animation frame.
public animate() {
let t = performance.now(); // Timestamp
let d = this.clock.getDelta(); // Animation Delta Time
// Spin the box
this.scene.getObjectByName('theBox').rotateX(0.1 * d);
this.scene.getObjectByName('theBox').rotateY(0.5 * d);
// Render Scene using Camera
this.renderer.render( this.scene, this.camera );
// Request the next Animation Frame
requestAnimationFrame( this.animate.bind(this) );
}
That's it, you should now be able to Start the PowerBI Visual using PBIVIZ Start, connec to PowerBI and add the developer visual. In a future post, I will show how to add 3D objects to the scene based upon data.
The Full Code of visual.ts is bellow:
/*
* Power BI Visual CLI
*
* Copyright (c) Microsoft Corporation
* All rights reserved.
* MIT License
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the ""Software""), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
"use strict";
import "core-js/stable";
import "./../style/visual.less";
import powerbi from "powerbi-visuals-api";
import VisualConstructorOptions = powerbi.extensibility.visual.VisualConstructorOptions;
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions;
import IVisual = powerbi.extensibility.visual.IVisual;
import EnumerateVisualObjectInstancesOptions = powerbi.EnumerateVisualObjectInstancesOptions;
import VisualObjectInstance = powerbi.VisualObjectInstance;
import DataView = powerbi.DataView;
import VisualObjectInstanceEnumerationObject = powerbi.VisualObjectInstanceEnumerationObject;
import * as THREE from "three";
import { VisualSettings } from "./settings";
export class Visual implements IVisual {
private target: HTMLElement;
private settings: VisualSettings;
private scene: THREE.Scene = new THREE.Scene();
private camera: THREE.PerspectiveCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 800);
private light: THREE.PointLight = new THREE.PointLight();
private renderer: THREE.WebGLRenderer = new THREE.WebGLRenderer({ antialias: true });
private clock: THREE.Clock = new THREE.Clock();
constructor(options: VisualConstructorOptions) {
console.log('Visual constructor', options);
this.target = options.element;
if (document) {
// Lights
this.light.position.set(0,0.8,2);
this.scene.add(this.light);
// Camera
this.camera.position.set(0,0.5,2);
this.camera.lookAt(0,0,0);
this.scene.add(this.camera);
// Renderer
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setClearColor(new THREE.Color(0xFFFFFF));
this.target.appendChild(this.renderer.domElement);
// Events
window.addEventListener("resize",this.onResize.bind(this));
// Draw Something
let boxGeometry = new THREE.BoxBufferGeometry(1,1,1);
let boxMaterial = new THREE.MeshPhongMaterial({ color: new THREE.Color(0xff0000) });
let box = new THREE.Mesh(boxGeometry,boxMaterial);
box.name = 'theBox';
this.scene.add(box);
this.animate();
}
}
public onResize() {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
}
public animate() {
let t = performance.now(); // Timestamp
let d = this.clock.getDelta(); // Animation Delta Time
// Spin the box
this.scene.getObjectByName('theBox').rotateX(0.1 * d);
this.scene.getObjectByName('theBox').rotateY(0.5 * d);
// Render Scene using Camera
this.renderer.render( this.scene, this.camera );
// Request the next Animation Frame
requestAnimationFrame( this.animate.bind(this) );
}
public update(options: VisualUpdateOptions) {
this.settings = Visual.parseSettings(options && options.dataViews && options.dataViews[0]);
console.log('Visual update', options);
}
private static parseSettings(dataView: DataView): VisualSettings {
return <VisualSettings>VisualSettings.parse(dataView);
}
/**
* This function gets called for each of the objects defined in the capabilities files and allows you to select which of the
* objects and properties you want to expose to the users in the property pane.
*
*/
public enumerateObjectInstances(options: EnumerateVisualObjectInstancesOptions): VisualObjectInstance[] | VisualObjectInstanceEnumerationObject {
return VisualSettings.enumerateObjectInstances(this.settings || VisualSettings.getDefault(), options);
}
}