Published
Edited
Jan 27, 2021
1 star
Insert cell
Insert cell
chart = {
// config
let start = false
let resetState = true
const SCREEN_WIDTH = width;
const SCREEN_HEIGHT = width*0.8;
const RESOLUTION = window.devicePixelRatio;
const NODE_RADIUS = 5;
const NODE_HIT_WIDTH = 5;
const NODE_HIT_RADIUS = NODE_RADIUS + NODE_HIT_WIDTH;
const TEXTURE_COLOR = 0xffffff;
const CIRCLE = 'CIRCLE';
let tick = 0
let color = ["0xf7524f", "0x4561cc", "0xf8bc2e", "0x17c3b2", "0x9336fd", "0xff928b"]
let dropdownIDs = ["1", "2", "3", "4", "5", "6"]
let config = {}
let attrs = {}
dropdownIDs.map((d,i)=>{
config[d] = null
attrs[d] = {idx: i, color: color[i], scale: i>=5 ? 2 : 1, offset: i===0 ? 110 : 90}
})

const container = html`<div style="position: relative"></div>`;
let type = 'radial'
const gridPositions = runRectLayout(data)
const radialPositions = runLayout(data)
let nodes = calcPositions(data, type)

// normalize node positions such that all elements fit on screen
const minNodeX = Math.min(...nodes.map(nodeData => nodeData.x));
const maxNodeX = Math.max(...nodes.map(nodeData => nodeData.x));
const minNodeY = Math.min(...nodes.map(nodeData => nodeData.y));
const maxNodeY = Math.max(...nodes.map(nodeData => nodeData.y));
const graphWidth = Math.abs(maxNodeX - minNodeX);
const graphHeight = Math.abs(maxNodeY - minNodeY);
const WORLD_WIDTH = Math.max(SCREEN_WIDTH * 2, graphWidth * 1.1);
const WORLD_HEIGHT = Math.max(SCREEN_HEIGHT * 2, graphHeight * 1.1);
nodes.forEach(nodeData => {
nodeData.x = nodeData.x - minNodeX - graphWidth / 2 + WORLD_WIDTH / 2;
nodeData.y = nodeData.y - minNodeY - graphHeight / 2 + WORLD_HEIGHT / 2;
nodeData.x1 = nodeData.x1 - minNodeX - graphWidth / 2 + WORLD_WIDTH / 2;
nodeData.y1 = nodeData.y1 - minNodeY - graphHeight / 2 + WORLD_HEIGHT / 2;
});

// create PIXI application
const app = new PIXI.Application({
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
resolution: RESOLUTION,
transparent: true,
antialias: true,
autoDensity: true,
autoStart: false // disable automatic rendering by ticker, render manually instead, only when needed
});

// create PIXI viewport
const viewport = new Viewport({
screenWidth: SCREEN_WIDTH,
screenHeight: SCREEN_HEIGHT,
worldWidth: WORLD_WIDTH,
worldHeight: WORLD_HEIGHT,
backgroundColor : 0x061639,
interaction: app.renderer.plugins.interaction,
});
const resetViewport = () => {
viewport.center = new PIXI.Point(WORLD_WIDTH / 2, WORLD_HEIGHT / 2);
viewport.fitWorld(true);
};
app.stage.addChild(viewport);
viewport.drag().pinch().wheel().decelerate().clampZoom({ minWidth: SCREEN_WIDTH, minHeight: SCREEN_HEIGHT });

// create layers
const nodesLayer = new PIXI.Container();
viewport.addChild(nodesLayer);

// create textures
const circleGraphics = new PIXI.Graphics();
circleGraphics.beginFill(TEXTURE_COLOR);
circleGraphics.drawCircle(NODE_RADIUS, NODE_RADIUS, NODE_RADIUS);
const circleTexture = app.renderer.generateTexture(circleGraphics, PIXI.settings.SCALE_MODE, RESOLUTION);

// create node graphics
const nodeDataGfxPairs = nodes.map(nodeData => {
const nodeGfx = new PIXI.Container();
nodeGfx.x = nodeData.x;
nodeGfx.y = nodeData.y;
nodeGfx.interactive = true;
nodeGfx.buttonMode = true;
nodeGfx.hitArea = new PIXI.Circle(0, 0, NODE_HIT_RADIUS);

const circle = new PIXI.Sprite(circleTexture);
circle.name = CIRCLE;
circle.x = -circle.width / 2;
circle.y = -circle.height / 2;
circle.tint = '#000';
circle.alpha = 0.5;
nodeGfx.addChild(circle);

nodesLayer.addChild(nodeGfx);

return [nodeData, nodeGfx];
});

// create lookup tables
let nodeDataToNodeGfx = new WeakMap(nodeDataGfxPairs.map(([nodeData, nodeGfx]) => [nodeData, nodeGfx]));
let nodeGfxToNodeData = new WeakMap(nodeDataGfxPairs.map(([nodeData, nodeGfx]) => [nodeGfx, nodeData]));

let nodesF = nodes.slice()
let nodesF_previous = nodesF

updatePositions(nodes);

resetViewport();
//app.render()
app.ticker.start()
container.appendChild(app.view);

// prevent body scrolling
app.view.addEventListener('wheel', event => { event.preventDefault(); });

function calcPositions(nodes, type){
//getRandomArbitrary(-1000, 1000): to create the mass movment of nodes on page load
//getRandomArbitrary(-10, 10): give final settling position of nodes some jitter, or it will look neatly packed after the radial force is executed on it
nodes.forEach((nodeData,i) => {
const position = radialPositions[i][1]
const g = gridPositions[i][1]
//const g = {x: nodeData.x, y: SCREEN_HEIGHT*2 - Math.random() * 150}
let randX = getRandomArbitrary(-10, 10)
let randY = getRandomArbitrary(-10, 10)
nodeData.id = i
nodeData.x1 = (type === 'grid' ? nodeData.x : position.x + getRandomArbitrary(-1000, 1000)) + randX; //initial node x-position
nodeData.y1 = (type === 'grid' ? nodeData.y : position.y + getRandomArbitrary(-1000, 1000)) + randY; //initial node y-position
nodeData.x = (type === 'grid' ? g.x : position.x) + randX
nodeData.y = (type === 'grid' ? g.y : position.y) + randY
nodeData.offset = 0
});
return nodes
}
function updatePositions(nodes) {
nodes.forEach(nodeData => {
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
nodeGfx.x = nodeData.x1;
nodeGfx.y = nodeData.y1;
});

let T = new PIXI.Ticker()
T.add((delta) => animateUpdate(delta, nodes, T, {idx: -1, color: "0x000000", scale: 1, offset: 0}, 'initial'));
T.start()
};
function rotate(ticker) {
nodesLayer.rotation -= 0.001;

nodesLayer.x = WORLD_WIDTH / 2;
nodesLayer.y = WORLD_HEIGHT / 2;

nodesLayer.pivot.x = WORLD_WIDTH / 2;
nodesLayer.pivot.y = WORLD_HEIGHT / 2;
nodes.forEach(nodeData => {
const nodeGfx = nodeDataToNodeGfx.get(nodeData);

let randX = getRandomArbitrary(-1, 1)
let randY = getRandomArbitrary(-1, 1)
let speed = (2 + Math.random() * 2) * 0.2;

//nodeGfx.position.x = (nodeData.x + randX);
//nodeGfx.position.y = (nodeData.y + randY);
nodeGfx.position.x += randX;
nodeGfx.position.y += randY;
});
tick += 1
if(start){
ticker.stop()
}
}
// Iterate over each data point and adjust coordinates
function animateUpdate(delta, data, ticker, attrs, reverse, type) {
data.forEach((d,i)=>{
getMoveNodes(d.id, type) //this function is called for each tick
})
tick += 1

function angle(cx, cy, ex, ey) {
var dy = ey - cy;
var dx = ex - cx;
var theta = Math.atan2(dy, dx);
return theta;
}
function inOutQuad(t){
t *= 2;
if ( t < 1 ) return 0.5 * t * t;
return - 0.5 * ( --t * ( t - 2 ) - 1 );
};
function getMoveNodes(id, type){
let n = data.find(d=>d.id === id)
let N = nodeDataToNodeGfx.get(n) //identify PIXI conatiner holding the element

//calculate angle between the element and a horizontal line to enable finding x-distance and y-distance of element from center
let angRad = angle(WORLD_WIDTH/2, WORLD_HEIGHT/2, n.x, n.y)
//element is moved inwards by amount indicated in attr.offset
let xDist, yDist
if(type === 'grid'){
xDist = 0
yDist = attrs.offset
} else {
xDist = attrs.offset * Math.cos(angRad)
yDist = attrs.offset * Math.sin(angRad)
}
//element is moved outwards to revert to original position (n.offset amount is different for each element)
let xDistR, yDistR
if(type === 'grid'){
xDistR = 0
yDistR = n.offset
} else {
xDistR = n.offset * Math.cos(angRad)
yDistR = n.offset * Math.sin(angRad)
}
let steps = reverse === 'initial' ? 80 : 20 //increase the number of steps to slow the transition
let scaleTo = attrs.scale
let t = 0
t = tick/steps
t = inOutQuad(t)
if(t < 1){
if(reverse === 'initial'){
let xDistR = (n.x1-n.x)
let yDistR = (n.y1-n.y)
N.position.x = n.x1 - xDistR*t // element is moved step-by-step with each tick
N.position.y = n.y1 - yDistR*t
} else if(reverse === 'reverse'){
if(n.offset){
N.position.x += xDistR/steps
N.position.y += yDistR/steps
if(n.scale === 2){
N.scale.set((1/steps*tick),(1/steps*tick));
}
}
} else {
N.position.x -= xDist/steps
N.position.y -= yDist/steps
}
if(scaleTo !== 1){
N.scale.set((scaleTo/steps*tick),(scaleTo/steps*tick));
}
const circle = N.getChildByName(CIRCLE);
circle.tint = attrs.color;
} else {
ticker.stop() //ticker will loop infinitely unless it is stopped
if(reverse === 'reverse'){
nodes.forEach(d=>{
d.offset = 0
})
nodesF = nodes
}
}
}
}

function run(group, reverse, type){
resetState = false
let index = dropdownIDs.indexOf(group)
let groupInt = parseInt(group)
config[groupInt] = group
nodesF_previous = nodesF
nodesF = nodesF.filter(d=>(d[groupInt] === group))
if(nodesF_previous.length !== nodesF.length){
nodesF.forEach((d,i)=>{
let idx = nodes.find(el=>el.id === d.id).id
nodes[idx].offset += attrs[groupInt].offset
nodes[idx].scale = index >= 5 ? 2 : 1
})
dropdownInteractivity(groupInt, nodesF, new PIXI.Ticker(), attrs[groupInt], reverse, type)
}
}

function dropdownInteractivity(id, data, ticker, attrs, reverse, type){
if(config[id] !== null){
tick = 0
if(data.length !== 0){
ticker.add((delta) => animateUpdate(delta, data, ticker, attrs, reverse, type));
ticker.start()
}
}
}

function animate(reverse, type){
if(resetState === false) return
let counter = 0
let timer = setInterval(function(){
run(dropdownIDs[counter], reverse, type)
counter += 1
resetState = false
if(dropdownIDs[counter] === undefined) {
clearInterval(timer)
};
}, 1000);
}
function reset(type){
if(resetState) return
resetState = true
dropdownInteractivity(0, nodes, new PIXI.Ticker(), {idx: 0, color: '0x000000', scale: 1, offset: null}, 'reverse', type)
dropdownIDs.map((d,i)=>{
config[d] = null
})
}
const gridButton = html`<button>Change to grid</button>`;
gridButton.addEventListener('click', () => {
tick = 0
type = 'grid'
nodes = calcPositions(nodes, type)
updatePositions(nodes)
});
const rotateButton = html`<button class='btn-rotate'>Rotate</button>`;
rotateButton.addEventListener('click', () => {
tick = 0
let t = new PIXI.Ticker()
t.add((delta) => rotate(t));
t.start()
start = !start
});
const animateButton = html`<button>Animate</button>`;
animateButton.addEventListener('click', () => animate(null, type));
const resetButton = html`<button>Reset</button>`;
resetButton.addEventListener('click', () => reset(type));
const toolbar = html`<div style="position: absolute; top: 5px; left: 10px"></div>`;
toolbar.appendChild(rotateButton);
toolbar.appendChild(gridButton);
toolbar.appendChild(animateButton);
toolbar.appendChild(resetButton);
container.appendChild(toolbar);
yield container

}
Insert cell
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
Insert cell
function runRectLayout(data) {

const positions = data.map((node,i) => {
return [i, { x: width * 2 * Math.random(), y: Math.random() * 150 + (width * 2-150) }];
});

return positions;
}
Insert cell
function runLayout(data) {

const iterations = 300;
const nodeRepulsion = -250;

d3.forceSimulation(data)
.force("charge", d3.forceCollide().radius(9))
.force("r", d3.forceRadial(function(d,i) { return 680; }))
.force('center', d3.forceCenter(0, 0))
.stop()
.tick(iterations);

const positions = data.map((node,i) => {
return [i, { x: node.x, y: node.y }];
});

return positions;
}
Insert cell
data = {
const nodes = [];
for (let i = 0; i < 2200; i++) {
let randGroup = Math.round(getRandomArbitrary(1, 6)).toString()
let randGroup1 = Math.round(getRandomArbitrary(1, 6)).toString()
let randGroup2 = Math.round(getRandomArbitrary(1, 6)).toString()
let randGroup3 = Math.round(getRandomArbitrary(1, 6)).toString()
let randGroup4 = Math.round(getRandomArbitrary(1, 6)).toString()
let randGroup5 = Math.round(getRandomArbitrary(1, 6)).toString()
nodes.push({
id: i,
[randGroup]: randGroup,
[randGroup1]: randGroup1,
[randGroup2]: randGroup2,
[randGroup3]: randGroup3,
[randGroup4]: randGroup4,
[randGroup5]: randGroup5,
});
}
return nodes;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more