Published
Edited
Nov 4, 2019
1 star
Insert cell
Insert cell
chart = {
// config
const SCREEN_WIDTH = width;
const SCREEN_HEIGHT = height;
//const WORLD_WIDTH = SCREEN_WIDTH * 2;
//const WORLD_HEIGHT = SCREEN_HEIGHT * 2;
//const RESOLUTION = window.devicePixelRatio * 2;
const WORLD_WIDTH = SCREEN_WIDTH;
const WORLD_HEIGHT = SCREEN_HEIGHT;
const RESOLUTION = window.devicePixelRatio;
const FORCE_LAYOUT_NODE_REPULSION_STRENGTH = 250;
const FORCE_LAYOUT_ITERATIONS = 300;
const NODE_RADIUS = 15;
const NODE_HIT_RADIUS = NODE_RADIUS + 5;
const ICON_FONT_FAMILY = 'Material Icons';
const ICON_FONT_SIZE = NODE_RADIUS / Math.SQRT2 * 2;
const ICON_TEXT = 'person';
const LABEL_FONT_FAMILY = 'Helvetica';
const LABEL_FONT_SIZE = 12;
const LABEL_TEXT = nodeData => nodeData.id;
const LABEL_X_PADDING = 2;
const LABEL_Y_PADDING = 1;

// static force-directed layout, running in WebWorker thread
const { nodes, links } = await forceLayout(data, {
iterations: FORCE_LAYOUT_ITERATIONS,
nodeRepulsionStrength: FORCE_LAYOUT_NODE_REPULSION_STRENGTH,
});
nodes.forEach(nodeData => {
nodeData.x += WORLD_WIDTH / 2;
nodeData.y += WORLD_HEIGHT / 2;
});
// preload font
await new FontFaceObserver(ICON_FONT_FAMILY).load();

// create PIXI application
const app = new PIXI.Application({
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
resolution: RESOLUTION,
transparent: true,
antialias: true,
autoStart: false // disable automatic rendering by ticker, render manually instead, only when needed
});
app.view.style.width = `${SCREEN_WIDTH}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,
interaction: app.renderer.plugins.interaction
});
const resetViewport = () => {
viewport.center = new PIXI.Point(WORLD_WIDTH / 2, WORLD_HEIGHT / 2);
viewport.setZoom(0.5, true);
};
app.stage.addChild(viewport);
viewport
.drag()
.pinch()
.wheel()
.decelerate();
viewport.on('frame-end', () => {
if (viewport.dirty) {
requestRender();
viewport.dirty = false;
}
});
// create 4 layers: links, nodes, labels, front
const linksLayer = new PIXI.Graphics();
viewport.addChild(linksLayer);
const nodesLayer = new PIXI.Container();
viewport.addChild(nodesLayer);
const labelsLayer = new PIXI.Container();
viewport.addChild(labelsLayer);
const frontLayer = new PIXI.Container();
viewport.addChild(frontLayer);

// state
let nodeDataToNodeGfx = new WeakMap();
let nodeGfxToNodeData = new WeakMap();
let nodeDataToLabelGfx = new WeakMap();
let labelGfxToNodeData = new WeakMap();
let hoveredNodeData = undefined;
let hoveredNodeGfxOriginalChildren = undefined;
let hoveredLabelGfxOriginalChildren = undefined;
let clickedNodeData = undefined;
const updatePositions = () => {
linksLayer.clear();
linksLayer.alpha = 0.6;
for (const link of links) {
linksLayer.lineStyle(Math.sqrt(link.value), 0x999999);
linksLayer.moveTo(link.source.x, link.source.y);
linksLayer.lineTo(link.target.x, link.target.y);
}
linksLayer.endFill();

for (const node of nodes) {
nodeDataToNodeGfx.get(node).position = new PIXI.Point(node.x, node.y)
nodeDataToLabelGfx.get(node).position = new PIXI.Point(node.x, node.y)
}
requestRender();
};
// event handlers
const hoverNode = nodeData => {
if (clickedNodeData) {
return;
}
if (hoveredNodeData === nodeData) {
return;
}
hoveredNodeData = nodeData;
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
const labelGfx = nodeDataToLabelGfx.get(nodeData);

// move to front layer
nodesLayer.removeChild(nodeGfx);
frontLayer.addChild(nodeGfx);
labelsLayer.removeChild(labelGfx);
frontLayer.addChild(labelGfx);
// add hover effect
hoveredNodeGfxOriginalChildren = [...nodeGfx.children];
hoveredLabelGfxOriginalChildren = [...labelGfx.children];

// circle border
const circleBorder = new PIXI.Graphics();
circleBorder.x = 0;
circleBorder.y = 0;
circleBorder.lineStyle(1.5, 0x000000);
circleBorder.drawCircle(0, 0, NODE_RADIUS);
nodeGfx.addChild(circleBorder);

// text with background
const labelText = new PIXI.Text(LABEL_TEXT(nodeData), {
fontFamily: LABEL_FONT_FAMILY,
fontSize: LABEL_FONT_SIZE,
fill: 0x333333
});
labelText.x = 0;
labelText.y = NODE_HIT_RADIUS + LABEL_Y_PADDING;
labelText.anchor.set(0.5, 0);
const labelBackground = new PIXI.Sprite(PIXI.Texture.WHITE);
labelBackground.x = -(labelText.width + LABEL_X_PADDING * 2) / 2;
labelBackground.y = NODE_HIT_RADIUS;
labelBackground.width = labelText.width + LABEL_X_PADDING * 2;
labelBackground.height = labelText.height + LABEL_Y_PADDING * 2;
labelBackground.tint = 0xeeeeee;
labelGfx.addChild(labelBackground);
labelGfx.addChild(labelText);
requestRender();
};
const unhoverNode = nodeData => {
if (clickedNodeData) {
return;
}
if (hoveredNodeData !== nodeData) {
return;
}
hoveredNodeData = undefined;
const nodeGfx = nodeDataToNodeGfx.get(nodeData);
const labelGfx = nodeDataToLabelGfx.get(nodeData);
// move back from front layer
frontLayer.removeChild(nodeGfx);
nodesLayer.addChild(nodeGfx);
frontLayer.removeChild(labelGfx);
labelsLayer.addChild(labelGfx);

// clear hover effect
const nodeGfxChildren = [...nodeGfx.children];
for (let child of nodeGfxChildren) {
if (!hoveredNodeGfxOriginalChildren.includes(child)) {
nodeGfx.removeChild(child);
}
}
hoveredNodeGfxOriginalChildren = undefined;
const labelGfxChildren = [...labelGfx.children];
for (let child of labelGfxChildren) {
if (!hoveredLabelGfxOriginalChildren.includes(child)) {
labelGfx.removeChild(child);
}
}
hoveredLabelGfxOriginalChildren = undefined;
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.Graphics();
circle.x = 0;
circle.y = 0;
circle.beginFill(colorToNumber(color(nodeData)));
circle.drawCircle(0, 0, NODE_RADIUS);
nodeGfx.addChild(circle);
const circleBorder = new PIXI.Graphics();
circle.x = 0;
circle.y = 0;
circleBorder.lineStyle(1.5, 0xffffff);
circleBorder.drawCircle(0, 0, NODE_RADIUS);
nodeGfx.addChild(circleBorder);

const icon = new PIXI.Text(ICON_TEXT, {
fontFamily: ICON_FONT_FAMILY,
fontSize: ICON_FONT_SIZE,
fill: 0xffffff
});
icon.x = 0;
icon.y = 0;
icon.anchor.set(0.5);
nodeGfx.addChild(icon);
const labelGfx = new PIXI.Container();
labelGfx.x = nodeData.x;
labelGfx.y = nodeData.y;
labelGfx.interactive = true;
labelGfx.buttonMode = true;
labelGfx.on('mouseover', event => hoverNode(labelGfxToNodeData.get(event.currentTarget)));
labelGfx.on('mouseout', event => unhoverNode(labelGfxToNodeData.get(event.currentTarget)));
labelGfx.on('mousedown', event => clickNode(labelGfxToNodeData.get(event.currentTarget)));
labelGfx.on('mouseup', () => unclickNode());
labelGfx.on('mouseupoutside', () => unclickNode());
const labelText = new PIXI.Text(LABEL_TEXT(nodeData), {
fontFamily: LABEL_FONT_FAMILY,
fontSize: LABEL_FONT_SIZE,
fill: 0x333333
});
labelText.x = 0;
labelText.y = NODE_HIT_RADIUS + LABEL_Y_PADDING;
labelText.anchor.set(0.5, 0);
const labelBackground = new PIXI.Sprite(PIXI.Texture.WHITE);
labelBackground.x = -(labelText.width + LABEL_X_PADDING * 2) / 2;
labelBackground.y = NODE_HIT_RADIUS;
labelBackground.width = labelText.width + LABEL_X_PADDING * 2;
labelBackground.height = labelText.height + LABEL_Y_PADDING * 2;
labelBackground.tint = 0xffffff;
labelBackground.alpha = 0.5;
labelGfx.addChild(labelBackground);
labelGfx.addChild(labelText);
nodesLayer.addChild(nodeGfx);
labelsLayer.addChild(labelGfx);

return [nodeData, nodeGfx, labelGfx];
});
// create lookup tables
nodeDataToNodeGfx = new WeakMap(nodeDataGfxPairs.map(([nodeData, nodeGfx, labelGfx]) => [nodeData, nodeGfx]));
nodeGfxToNodeData = new WeakMap(nodeDataGfxPairs.map(([nodeData, nodeGfx, labelGfx]) => [nodeGfx, nodeData]));
nodeDataToLabelGfx = new WeakMap(nodeDataGfxPairs.map(([nodeData, nodeGfx, labelGfx]) => [nodeData, labelGfx]));
labelGfxToNodeData = new WeakMap(nodeDataGfxPairs.map(([nodeData, nodeGfx, labelGfx]) => [labelGfx, nodeData]));

// initial draw
resetViewport();
updatePositions();

// destroy PIXI application on Observable cell invalidation
invalidation.then(() => {
app.destroy(true, true);
});

// prevent body scrolling
app.view.addEventListener('wheel', event => { event.preventDefault(); });
// create container with toolbar
const resetButton = html`<button>Reset</button>`;
resetButton.addEventListener('click', () => resetViewport());
const toolbar = html`<div style="position: absolute; top: 5px; left: 10px"></div>`;
toolbar.appendChild(resetButton);
const container = html`<div style="position: relative"></div>`;
container.appendChild(toolbar);
container.appendChild(app.view);
return container;
}
Insert cell
data = {
const { nodes, links } = await d3.json("https://gist.githubusercontent.com/mbostock/4062045/raw/5916d145c8c048a6e3086915a6be464467391c62/miserables.json")
return {
nodes: nodes
.concat(nodes.map((node) => ({ ...node, id: node.id + '1' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '2' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '3' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '4' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '5' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '6' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '7' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '8' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '9' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '10' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '11' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '12' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '13' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '14' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '15' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '16' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '17' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '18' })))
.concat(nodes.map((node) => ({ ...node, id: node.id + '19' }))),
links: links
.concat(links.map((link) => ({ ...link, source: link.source + '', target: link.target + '1' })))
.concat(links.map((link) => ({ ...link, source: link.source + '1', target: link.target + '2' })))
.concat(links.map((link) => ({ ...link, source: link.source + '2', target: link.target + '3' })))
.concat(links.map((link) => ({ ...link, source: link.source + '3', target: link.target + '4' })))
.concat(links.map((link) => ({ ...link, source: link.source + '4', target: link.target + '5' })))
.concat(links.map((link) => ({ ...link, source: link.source + '5', target: link.target + '6' })))
.concat(links.map((link) => ({ ...link, source: link.source + '6', target: link.target + '7' })))
.concat(links.map((link) => ({ ...link, source: link.source + '7', target: link.target + '8' })))
// .concat(links.map((link) => ({ ...link, source: link.source + '8', target: link.target + '9' })))
.concat(links.map((link) => ({ ...link, source: link.source + '9', target: link.target + '10' })))
.concat(links.map((link) => ({ ...link, source: link.source + '10', target: link.target + '11' })))
.concat(links.map((link) => ({ ...link, source: link.source + '11', target: link.target + '12' })))
.concat(links.map((link) => ({ ...link, source: link.source + '12', target: link.target + '13' })))
.concat(links.map((link) => ({ ...link, source: link.source + '13', target: link.target + '14' })))
.concat(links.map((link) => ({ ...link, source: link.source + '14', target: link.target + '15' })))
.concat(links.map((link) => ({ ...link, source: link.source + '15', target: link.target + '16' })))
.concat(links.map((link) => ({ ...link, source: link.source + '16', target: link.target + '17' })))
.concat(links.map((link) => ({ ...link, source: link.source + '17', target: link.target + '18' })))
.concat(links.map((link) => ({ ...link, source: link.source + '18', target: link.target + '19' })))
}
}
Insert cell
height = 600
Insert cell
color = {
const scale = d3.scaleOrdinal(d3.schemeCategory10);
return nodeData => scale(nodeData.group);
}
Insert cell
colorToNumber = color => parseInt(color.slice(1), 16)
Insert cell
forceLayout = (...args) => {
// return new Promise(resolve => {
// const workerCode = `
// importScripts('https://unpkg.com/d3@5.12.0/dist/d3.min.js');

// function forceLayout(data, options = {}) {
// const nodes = data.nodes;
// const links = data.links;

// const iterations = options.iterations;
// const nodeRepulsionStrength = options.nodeRepulsionStrength;

// d3.forceSimulation(nodes)
// .force("link", d3.forceLink(links).id(linkData => linkData.id))
// .force("charge", d3.forceManyBody().strength(-nodeRepulsionStrength))
// .force("center", d3.forceCenter())
// .stop()
// .tick(iterations);

// return { nodes, links };
// };

// self.onmessage = event => {
// const result = forceLayout.apply(undefined, event.data);
// postMessage(result);
// }
// `;

// const workerBlob = new Blob([workerCode], { type: 'application/javascript' });
// const workerUrl = URL.createObjectURL(workerBlob)
// const worker = new Worker(workerUrl);

// worker.onmessage = event => {
// resolve(event.data);
// worker.terminate();
// URL.revokeObjectURL(workerUrl);
// };
// worker.postMessage(args);
// });
return new Promise((resolve) => {
const [data, { iterations, nodeRepulsionStrength }] = args
d3.forceSimulation(data.nodes)
.force("link", d3.forceLink(data.links).id(linkData => linkData.id))
.force("charge", d3.forceManyBody().strength(-nodeRepulsionStrength))
.force("center", d3.forceCenter())
.stop()
.tick(iterations);
resolve(data)
})
}
Insert cell
d3 = require("d3@5")
Insert cell
html`<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">`
Insert cell
FontFaceObserver = require('fontfaceobserver@2.1.0').catch(() => window.FontFaceObserver)
Insert cell
PIXI = require('pixi.js@5.1.2/dist/pixi.min.js').catch(() => window.PIXI)
Insert cell
Viewport = (await require('https://bundle.run/pixi-viewport@4.2.2/dist/viewport.js')).Viewport
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