chart = {
const SCREEN_WIDTH = width;
const SCREEN_HEIGHT = height;
const RESOLUTION = window.devicePixelRatio;
const NODE_RADIUS = 5;
const NODE_BORDER_WIDTH = 0;
const NODE_BORDER_COLOR = 0xffffff;
const NODE_BORDER_RADIUS = NODE_RADIUS + NODE_BORDER_WIDTH;
const NODE_HIT_WIDTH = 5;
const NODE_HIT_RADIUS = NODE_RADIUS + NODE_HIT_WIDTH;
const NODE_HOVER_BORDER_COLOR = 0x000000;
const TEXTURE_COLOR = 0xffffff;
const CIRCLE = 'CIRCLE';
const CIRCLE_BORDER = 'CIRCLE_BORDER';
yield html`<div style="height: ${SCREEN_HEIGHT}px; font-size: 0.85rem">Computing layout...</div>`;
const { positions, layoutTime } = await runLayout(graph, config.layout);
const { nodes, links } = graph;
nodes.forEach(nodeData => {
const position = positions[nodeData.id];
nodeData.x = position.x + getRandomArbitrary(-10, 10);
nodeData.y = position.y + getRandomArbitrary(-10, 10);
});
yield html`<div style="height: ${SCREEN_HEIGHT}px; font-size: 0.85rem">Rendering...</div>`;
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;
});
// create PIXI application
console.log(graphWidth, graphHeight, SCREEN_WIDTH, SCREEN_HEIGHT, WORLD_WIDTH, WORLD_HEIGHT, RESOLUTION);
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
});
app.view.style.width = `${SCREEN_WIDTH}px`;
//app.view.style.transform = `translate(${SCREEN_WIDTH/2}, ${SCREEN_HEIGHT}px`;
// manual rendering
// app.renderer.on('postrender', () => { console.log('render'); });
let renderRequestId = undefined;
const requestRender = () => {
if (renderRequestId) {
return;
}
renderRequestId = window.requestAnimationFrame(() => {
app.render();
renderRequestId = undefined;
});
}
// 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 zoomIn = () => {
viewport.zoom(-WORLD_WIDTH / 10, true);
};
const zoomOut = () => {
viewport.zoom(WORLD_WIDTH / 10, true);
};
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: links, front links, nodes, front nodes, front labels
const nodesLayer = new PIXI.Container();
viewport.addChild(nodesLayer);
const frontNodesLayer = new PIXI.Container();
viewport.addChild(frontNodesLayer);
// create textures: circle, circle border, icons
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);
const circleBorderGraphics = new PIXI.Graphics();
circleBorderGraphics.lineStyle(NODE_BORDER_WIDTH, TEXTURE_COLOR);
circleBorderGraphics.drawCircle(NODE_BORDER_RADIUS, NODE_BORDER_RADIUS, NODE_RADIUS);
const circleBorderTexture = app.renderer.generateTexture(circleBorderGraphics, PIXI.settings.SCALE_MODE, RESOLUTION);
// state
let nodeDataToNodeGfx = new WeakMap();
let nodeGfxToNodeData = new WeakMap();
let hoveredNodeData = undefined;
let clickedNodeData = undefined;
const updatePositions = () => {
nodes.forEach(nodeData => {
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
nodeGfx.x = nodeData.x;
nodeGfx.y = nodeData.y;
});
requestRender();
};
const round = value => Math.round(value * 1000) / 1000;
const updateVisibility = () => {
// culling
const cull = new PIXI.Cull();
cull.addAll(nodesLayer.children);
cull.cull(app.renderer.screen);
// console.log(
// [...cull._targetList].filter(x => x.visible === true).length,
// [...cull._targetList].filter(x => x.visible === false).length
// );
// levels of detail
const zoom = viewport.scale.x;
const zoomSteps = [0.1, 0.2, 0.4, Infinity];
const zoomStep = zoomSteps.findIndex(zoomStep => zoom <= zoomStep);
nodes.forEach(nodeData => {
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
const circleBorder = nodeGfx.getChildByName(CIRCLE_BORDER);
circleBorder.visible = zoomStep >= 1;
});
};
// event handlers
const hoverNode = nodeData => {
if (clickedNodeData) {
return;
}
if (hoveredNodeData === nodeData) {
return;
}
hoveredNodeData = nodeData;
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
// move to front layer
nodesLayer.removeChild(nodeGfx);
frontNodesLayer.addChild(nodeGfx);
// add hover effect
const circleBorder = nodeGfx.getChildByName(CIRCLE_BORDER);
circleBorder.tint = NODE_HOVER_BORDER_COLOR;
requestRender();
};
const unhoverNode = nodeData => {
if (clickedNodeData) {
return;
}
if (hoveredNodeData !== nodeData) {
return;
}
hoveredNodeData = undefined;
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
// move back from front layer
frontNodesLayer.removeChild(nodeGfx);
nodesLayer.addChildAt(nodeGfx, nodes.indexOf(nodeData));
// clear hover effect
const circleBorder = nodeGfx.getChildByName(CIRCLE_BORDER);
circleBorder.tint = NODE_BORDER_COLOR;
requestRender();
};
const moveNode = (nodeData, point) => {
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
nodeData.x = point.x;
nodeData.y = point.y;
updatePositions();
};
const appMouseMove = event => {
if (!clickedNodeData) {
return;
}
moveNode(clickedNodeData, viewport.toWorld(event.data.global));
};
const clickNode = nodeData => {
clickedNodeData = nodeData;
// enable node dragging
app.renderer.plugins.interaction.on('mousemove', appMouseMove);
// disable viewport dragging
viewport.pause = true;
};
const unclickNode = () => {
clickedNodeData = undefined;
// disable node dragging
app.renderer.plugins.interaction.off('mousemove', appMouseMove);
// enable viewport dragging
viewport.pause = false;
};
// 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);
//nodeGfx.on('mouseover', event => hoverNode(nodeGfxToNodeData.get(event.currentTarget)));
//nodeGfx.on('mouseout', event => unhoverNode(nodeGfxToNodeData.get(event.currentTarget)));
//nodeGfx.on('mousedown', event => clickNode(nodeGfxToNodeData.get(event.currentTarget)));
//nodeGfx.on('mouseup', () => unclickNode());
//nodeGfx.on('mouseupoutside', () => unclickNode());
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);
const circleBorder = new PIXI.Sprite(circleBorderTexture);
circleBorder.name = CIRCLE_BORDER;
circleBorder.x = -circleBorder.width / 2;
circleBorder.y = -circleBorder.height / 2;
circleBorder.tint = NODE_BORDER_COLOR;
nodeGfx.addChild(circleBorder);
nodesLayer.addChild(nodeGfx);
return [nodeData, nodeGfx];
});
// create lookup tables
nodeDataToNodeGfx = new WeakMap(nodeDataGfxPairs.map(([nodeData, nodeGfx]) => [nodeData, nodeGfx]));
nodeGfxToNodeData = new WeakMap(nodeDataGfxPairs.map(([nodeData, nodeGfx]) => [nodeGfx, nodeData]));
// initial draw
resetViewport();
updatePositions();
viewport.on('frame-end', () => {
if (viewport.dirty) {
updateVisibility();
requestRender();
viewport.dirty = false;
}
});
// destroy PIXI application on Observable cell invalidation
invalidation.then(() => {
if (renderRequestId) {
window.cancelAnimationFrame(renderRequestId);
renderRequestId = undefined;
}
app.destroy(true, true);
});
// prevent body scrolling
app.view.addEventListener('wheel', event => { event.preventDefault(); });
let tick = 0
// Listen for animate update
app.ticker.add((delta) => {
// rotate the container!
// use delta to create frame-independent transform
//nodesLayer.rotation -= 0.001 * delta;
//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);
//const labelGfx = nodeDataToLabelGfx.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;
//});
let nodeIDs = [18, 39, 80, 120, 180, 190]
nodes.slice(200,400).forEach((d,i)=>{
getMoveNodes(i)
})
tick += 1
function getMoveNodes(id){
let N = nodeDataToNodeGfx.get(nodes[id])
let xDist, yDist
if(nodes[id].x > WORLD_WIDTH/2){
xDist = -WORLD_WIDTH/2 + nodes[id].x + getRandomArbitrary(-200, 200)
} else {
xDist = nodes[id].x - WORLD_WIDTH/2 + getRandomArbitrary(-200, 200)
}
if(nodes[id].y > WORLD_HEIGHT/2){
yDist = -WORLD_HEIGHT/2 + nodes[id].y + getRandomArbitrary(-200, 200)
} else {
yDist = nodes[id].y - WORLD_HEIGHT/2 + getRandomArbitrary(-200, 200)
}
let steps = 50
let scaleTo = 3
if(tick <= 50) {
//N.position.x += xDist/steps
//N.position.y += yDist/steps
//N.scale.set((scaleTo/steps*tick),(scaleTo/steps*tick));
const circle = N.getChildByName(CIRCLE);
circle.tint = 0xff0000;
circle.x -= xDist/steps
circle.y -= yDist/steps
}
}
});
app.ticker.start()
// create container
const container = html`<div style="position: relative"></div>`;
const toolbar = html`<div style="position: absolute; top: 0; left: 0; margin: 5px"></div>`;
container.appendChild(toolbar);
const zoomInButton = html`<button>+</button>`;
zoomInButton.addEventListener('click', () => zoomIn());
toolbar.appendChild(zoomInButton);
const zoomOutButton = html`<button>−</button>`;
zoomOutButton.addEventListener('click', () => zoomOut());
toolbar.appendChild(zoomOutButton);
const resetButton = html`<button>Reset</button>`;
resetButton.addEventListener('click', () => resetViewport());
toolbar.appendChild(resetButton);
const infoText = html`<div style="margin-top: 10px; font-size: 0.85rem; text-shadow: 1px 1px white, 1px -1px white, -1px 1px white, -1px -1px white">
<strong>Layout stats:</strong><br>
Layout: ${config.layout}<br>
Time: ${formatInt(layoutTime)} ms<br>
Readability score: <span id="readability-score">Computing...</span><br>
</div>`;
const readabilityScoreText = infoText.querySelector('#readability-score');
toolbar.appendChild(infoText);
if (config.showFpsMeter) {
const fpsMeterContainer = html`<div style="position: absolute; top: 0; right: 0; margin: 5px"></div>`;
fpsMeterContainer.appendChild(fpsMeter());
container.appendChild(fpsMeterContainer);
}
container.appendChild(app.view);
yield container;
readabilityScoreText.innerHtml = 'Computing...';
const layoutReadability = await getLayoutReadability(graph);
readabilityScoreText.innerText = formatFloat(layoutReadability);
}