Published
Edited
Jan 27, 2021
1 fork
8 stars
Insert cell
Insert cell
chart = {
// config
let resetState = true
const SCREEN_WIDTH = width;
const SCREEN_HEIGHT = width;
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 IDs = ["1", "2", "3", "4", "5", "6"]
let config = {}
let attrs = {}
IDs.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>`;

const positions = runLayout(nodes) // run force simulation to radially position nodes

//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 = positions[i][1];
let randX = getRandomArbitrary(-10, 10)
let randY = getRandomArbitrary(-10, 10)
nodeData.id = i
nodeData.x1 = position.x + getRandomArbitrary(-1000, 1000) + randX;
nodeData.y1 = position.y + getRandomArbitrary(-1000, 1000) + randY;
nodeData.x = position.x + randX
nodeData.y = position.y + randY
nodeData.offset = 0
});

// 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(); // kick off initial transition on page load

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

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


function updatePositions() {
nodes.forEach(nodeData => {
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
nodeGfx.x = nodeData.x1;
nodeGfx.y = nodeData.y1;
});

let T = new PIXI.Ticker()
T.add((delta) => animate(delta, nodes, T, {idx: -1, color: "0x000000", scale: 1, offset: 0}, 'initial'));
T.start()
};

// Iterate over each data point and adjust coordinates
function animate(delta, data, ticker, attrs, reverse) {

data.forEach((d,i)=>{
move(d.id) //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 move(id){
let n = nodes.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, nodes[id].x, nodes[id].y)
//element is moved inwards by amount indicated in attr.offset
let xDist, yDist
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
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

if(tick <= steps) {
if(reverse === 'initial'){
let xDistR = (n.x1-n.x)
let yDistR = (n.y1-n.y)
N.position.x -= xDistR/steps // element is moved step-by-step with each tick
N.position.y -= yDistR/steps
} 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){
resetState = false
let index = IDs.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
})
startTicker(groupInt, nodesF, new PIXI.Ticker(), attrs[groupInt])
}
}

function startTicker(id, nodes, ticker, attrs, reverse){
if(config[id] !== null){
tick = 0
if(nodes.length !== 0){
ticker.add((delta) => animate(delta, nodes, ticker, attrs, reverse));
ticker.start()
}
}
}

function loop(){
if(resetState === false) return
let counter = 0
let timer = setInterval(function(){
run(IDs[counter])
counter += 1
resetState = false
if(IDs[counter] === undefined) {
clearInterval(timer)
};
}, 1000);
}

function reset(){
if(resetState) return
resetState = true
startTicker(0, nodes, new PIXI.Ticker(), {idx: 0, color: '0x000000', scale: 1, offset: null}, 'reverse')
IDs.map((d,i)=>{
config[d] = null
})
}

const animateButton = html`<button>Animate</button>`;
animateButton.addEventListener('click', () => loop());
const resetButton = html`<button>Reset</button>`;
resetButton.addEventListener('click', () => reset());
const toolbar = html`<div style="position: absolute; top: 5px; left: 10px"></div>`;
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 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
nodes = {
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
import {inputsForm} from '@zakjan/inputs-form'
Insert cell
import {select} from '@jashkenas/inputs'
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