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

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more