showcases/graph/app.js (349 lines of code) (raw):
/* global window,document */
import React, {Component} from 'react';
import {render} from 'react-dom';
import {json as requestJSON, csv as requestCSV} from 'd3-request';
import DeckGLOverlay from './deckgl-overlay';
import {default as GraphBasic} from './graph-layer/adaptor/graph-basic';
import {default as GraphFlare} from './graph-layer/adaptor/graph-flare';
import {default as GraphSNAP} from './graph-layer/adaptor/graph-snap';
// change this to load a different sample dataset:
// 0: typed data, with types represented as icons
// 1: named data, to show labeling on interaction
// 2: larger dataset
const DATASET = 1;
class Root extends Component {
//
// React lifecycle
//
constructor(props) {
super(props);
this._resize = this._resize.bind(this);
this._animate = this._animate.bind(this);
this._onHover = this._onHover.bind(this);
this._onClick = this._onClick.bind(this);
this._onMouseDown = this._onMouseDown.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseUp = this._onMouseUp.bind(this);
this._getNodeColor = this._getNodeColor.bind(this);
// set initial state
this.state = {
viewport: {
width: 500,
height: 500
},
data: null,
iconMapping: null,
hovered: null,
clicked: null,
dragging: null,
lastDragged: null
};
/* eslint-disable max-len */
const dataConfig = [
{
data:
'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/graph/sample-graph.json',
loader: requestJSON,
adaptor: GraphBasic,
hasNodeTypes: true
},
{
data:
'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/graph/flare.json',
loader: requestJSON,
adaptor: GraphFlare,
hasNodeTypes: false
},
{
data:
'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/graph/facebook-SNAP.csv',
loader: requestCSV,
adaptor: GraphSNAP,
hasNodeTypes: false
}
];
/* eslint-enable max-len */
const loader = dataConfig[DATASET].loader;
loader(dataConfig[DATASET].data, (error, response) => {
if (!error) {
// "adaptors" are used to parse data into the format
// required by your layout (e.g. nodes and links arrays for d3-force),
// and to manage addition / removal of graph elements.
const GraphAdaptor = dataConfig[DATASET].adaptor;
const graph = new GraphAdaptor(response);
this.setState({
data: [graph]
});
}
});
// only set up icon accessors for sample datasets that have types to be represented as icons.
if (dataConfig[DATASET].hasNodeTypes) {
// load icon atlas
requestJSON('./data/node-icon-atlas.json', (error, response) => {
if (!error) {
this.setState({
iconMapping: response
});
}
});
}
}
componentDidMount() {
window.addEventListener('resize', this._resize);
this._resize();
this._animate();
}
shouldComponentUpdate() {
// Prevent component from updating while animation is running,
// to ensure layout updating and rendering are synchronized.
return !this._animationFrame;
}
componentDidUpdate() {
/* eslint-disable react/no-did-update-set-state */
// lastDragged state lasts only one frame
const {lastDragged} = this.state;
if (lastDragged) {
this.setState({
lastDragged: null
});
}
/* eslint-enable react/no-did-update-set-state */
}
componentWillUnmount() {
this._stopAnimation();
}
_animate() {
this.forceUpdate();
if (typeof window !== 'undefined') {
this._animationFrame = window.requestAnimationFrame(this._animate);
}
}
_stopAnimation() {
window.cancelAnimationFrame(this._animationFrame);
this._animationFrame = null;
}
_resize() {
this._onViewportChange({
width: window.innerWidth,
height: window.innerHeight
});
}
_onViewportChange(viewport) {
this.setState({
viewport: {...this.state.viewport, ...viewport}
});
}
_onHover(info) {
if (info) {
this.setState({hovered: info});
}
}
_onClick(info) {
if (info) {
this.setState({clicked: info});
} else {
const {clicked} = this.state;
if (clicked) {
this.setState({clicked: null});
}
}
}
_onMouseDown(event) {
// Use DeckGL.pickObject() to find the object under the mouse,
// and store it for updating on mouse move.
const info = this.deckGL.pickObject({x: event.clientX, y: event.clientY});
if (info) {
this.setState({
clicked: info,
dragging: {
node: info.object
}
});
this._updateDraggedElement([event.clientX, event.clientY], info.object);
}
}
_onMouseMove(event) {
if (this.state.dragging) {
this._updateDraggedElement([event.clientX, event.clientY], this.state.dragging.node);
}
}
_onMouseUp(event) {
if (this.state.dragging) {
this.setState({
dragging: null,
lastDragged: {
node: this.state.dragging.node
}
});
}
}
_updateDraggedElement(point, node) {
const {
viewport: {width, height}
} = this.state;
const x = point[0] - width / 2;
const y = point[1] - height / 2;
this.setState({
dragging: {
node,
x,
y
}
});
}
//
// layout (d3-force) accessors
//
_linkDistance(link, i) {
return 20;
}
_linkStrength(link, i) {
if (link.sourceCount || link.targetCount) {
return 1 / Math.min(link.sourceCount, link.targetCount);
}
return 0.5;
}
_nBodyStrength(node, i) {
if (node.size) {
return -Math.pow(node.size, 1.5) * 3;
}
return -60;
}
//
// rendering (deck.gl) accessors
//
_getNodeColor(node) {
return [18, 147, 154, 255];
}
_getNodeSize(node) {
return node.size || 8;
}
// map node type to icon in texture atlas
_getNodeIcon(node) {
switch (node.type) {
case 0:
return 'burger';
case 1:
return 'fries';
case 2:
return 'soda';
case 3:
case 4:
return 'pie';
default:
return null;
}
}
//
// rendering
//
/**
* The "interaction layer" is an SVG overlay used to apply highlights
* and labels to the targets of interaction (hovered and clicked/dragged elements).
*/
_renderInteractionLayer(viewport, hovered, clicked) {
// set flags used below to determine if SVG highlight elements should be rendered.
// if truthy, each flag is replaced with the corresponding element to render.
const elements = {
hovered: hovered && hovered.object,
clicked: clicked && clicked.object
};
const relatedElements = {
hovered: hovered && hovered.relatedObjects,
clicked: clicked && clicked.relatedObjects
};
const elementInfo = {
hovered: hovered && hovered.object,
clicked: clicked && clicked.object
};
// process related elements first, since they compare themselves to the focused elements.
// related elements are:
// - all links and nodes attached to a node; or
// - the nodes at each end of a link.
// see `graph-layout-layer::getPickingInfo()` for more information.
Object.keys(relatedElements).forEach(k => {
const els = relatedElements[k];
if (!els || !els.length) {
relatedElements[k] = null;
} else {
relatedElements[k] = [];
els.forEach(el => {
relatedElements[k].push(this._renderInteractionElement(el, `related ${k}`, viewport));
});
}
});
// process the focused (hovered / clicked) elements
Object.keys(elements).forEach(k => {
const el = elements[k];
elements[k] = el ? this._renderInteractionElement(el, k, viewport) : null;
});
// render additional info about the focused elements (only nodes, not links)
Object.keys(elementInfo).forEach(k => {
const el = elementInfo[k];
if (el && el.name) {
elementInfo[k] = (
<text x={el.x} y={el.y} dx={this._getNodeSize(el) + 10} dy={-10}>
{el.name}
</text>
);
} else {
elementInfo[k] = null;
}
});
// Note: node.x/y, calculated by d3 layout,
// is measured from the center of the layout (of the viewport).
// Therefore, we offset the <g> container to align.
return (
<svg width={viewport.width} height={viewport.height} className="interaction-overlay">
<g transform={`translate(${viewport.width / 2},${viewport.height / 2})`}>
{relatedElements.hovered}
{elements.hovered}
{relatedElements.clicked}
{elements.clicked}
{elementInfo.hovered}
{elementInfo.clicked}
</g>
</svg>
);
}
_renderInteractionElement(el, className, viewport) {
let element;
if (el.source) {
// link
element = (
<line
x1={el.source.x}
y1={el.source.y}
x2={el.target.x}
y2={el.target.y}
className={className}
key={`link-${className}-${el.id}`}
/>
);
} else {
// node
element = (
<circle
cx={el.x}
cy={el.y}
r={this._getNodeSize(el)}
className={className}
key={`node-${className}-${el.id}`}
/>
);
}
return element;
}
render() {
const {viewport, data} = this.state;
const {hovered, clicked, dragging, lastDragged} = this.state;
const layoutProps = {
fixedNodes: dragging ? [dragging] : null,
unfixedNodes: lastDragged ? [lastDragged] : null
};
const layoutAccessors = {
linkDistance: this._linkDistance,
linkStrength: this._linkStrength,
nBodyStrength: this._nBodyStrength
};
const linkAccessors = {};
const nodeAccessors = {
getNodeColor: this._getNodeColor,
getNodeSize: this._getNodeSize
};
let nodeIconAccessors;
const {iconMapping} = this.state;
if (iconMapping) {
// pass icon accessors if icon mapping is loaded
nodeIconAccessors = {
getIcon: this._getNodeIcon.bind(this),
iconAtlas: './data/node-icon-atlas.png',
iconMapping,
sizeScale: 4
};
}
return (
<div
onMouseDown={this._onMouseDown}
onMouseMove={this._onMouseMove}
onMouseUp={this._onMouseUp}
>
<DeckGLOverlay
// eslint-disable-next-line no-return-assign
deckGLRef={ref => (this.deckGL = ref)}
viewport={viewport}
data={data}
onHover={this._onHover}
onClick={this._onClick}
layoutProps={layoutProps}
layoutAccessors={layoutAccessors}
linkAccessors={linkAccessors}
nodeAccessors={nodeAccessors}
nodeIconAccessors={nodeIconAccessors}
/>
{this._renderInteractionLayer(viewport, hovered, dragging || clicked)}
</div>
);
}
}
render(<Root />, document.body.appendChild(document.createElement('div')));