This program renders a network diagram for a ModelJS reactive flow.
The input data is generated by an experimental ModelJS branch that computes the reactive flow graph at runtime.
Based on a previous implementation from July 2014
This program renders a network diagram for a ModelJS reactive flow.
The input data is generated by an experimental ModelJS branch that computes the reactive flow graph at runtime.
Based on a previous implementation from July 2014
{"nodes":[{"type":"lambda","fixed":1,"x":-108,"y":308},{"type":"property","property":"container","fixed":1,"x":-233,"y":306},{"type":"property","property":"svg","fixed":1,"x":-5,"y":309},{"type":"lambda","fixed":1,"x":214,"y":176},{"type":"property","property":"box","fixed":1,"x":-200,"y":257},{"type":"lambda","fixed":1,"x":80,"y":311},{"type":"property","property":"g","fixed":1,"x":165,"y":311},{"type":"lambda","fixed":1,"x":212,"y":264},{"type":"property","property":"margin","fixed":1,"x":-222,"y":202},{"type":"lambda","fixed":1,"x":231,"y":369},{"type":"property","property":"titleText","fixed":1,"x":388,"y":347},{"type":"lambda","fixed":1,"x":578,"y":391},{"type":"property","property":"titleOffset","fixed":1,"x":430,"y":401},{"type":"lambda","fixed":1,"x":214,"y":216},{"type":"property","property":"width","fixed":1,"x":497,"y":63},{"type":"property","property":"height","fixed":1,"x":485,"y":555},{"type":"lambda","fixed":1,"x":653,"y":123},{"type":"property","property":"xAxisG","fixed":1,"x":782,"y":65},{"type":"property","property":"xAxisText","fixed":1,"x":785,"y":119},{"type":"lambda","fixed":1,"x":963,"y":159},{"type":"property","property":"xAxisLabelOffset","fixed":1,"x":-292,"y":153},{"type":"lambda","fixed":1,"x":955,"y":53},{"type":"lambda","fixed":1,"x":963,"y":107},{"type":"lambda","fixed":1,"x":963,"y":221},{"type":"property","property":"xAxisLabel","fixed":1,"x":-257,"y":104},{"type":"lambda","fixed":1,"x":598,"y":332},{"type":"property","property":"yAxisG","fixed":1,"x":811,"y":442},{"type":"property","property":"yAxisText","fixed":1,"x":790,"y":390},{"type":"lambda","fixed":1,"x":946,"y":335},{"type":"property","property":"yAxisLabelOffset","fixed":1,"x":-286,"y":418},{"type":"lambda","fixed":1,"x":946,"y":463},{"type":"lambda","fixed":1,"x":944,"y":407},{"type":"property","property":"yAxisLabel","fixed":1,"x":-248,"y":466},{"type":"lambda","fixed":1,"x":674,"y":271},{"type":"property","property":"barsG","fixed":1,"x":1133,"y":312},{"type":"lambda","fixed":1,"x":589,"y":235},{"type":"lambda","fixed":true,"x":-87,"y":137},{"type":"property","property":"data","fixed":1,"x":-203,"y":365},{"type":"property","property":"xAttribute","fixed":1,"x":-242,"y":54},{"type":"property","property":"getX","fixed":1,"x":147,"y":92},{"type":"lambda","fixed":1,"x":17,"y":26},{"type":"property","property":"sortField","fixed":1,"x":-234,"y":2},{"type":"property","property":"sortOrder","fixed":1,"x":-235,"y":-51},{"type":"property","property":"sortedData","fixed":1,"x":157,"y":17},{"type":"lambda","fixed":1,"x":-70,"y":499},{"type":"property","property":"yAttribute","fixed":1,"x":-233,"y":521},{"type":"property","property":"getY","fixed":1,"x":61,"y":525},{"type":"lambda","fixed":1,"x":281,"y":585},{"type":"property","property":"yDomainMin","fixed":1,"x":-252,"y":573},{"type":"property","property":"yDomainMax","fixed":1,"x":-255,"y":625},{"type":"property","property":"yDomain","fixed":1,"x":475,"y":614},{"type":"lambda","fixed":1,"x":678,"y":566},{"type":"property","property":"yScale","fixed":1,"x":815,"y":565},{"type":"lambda","fixed":1,"x":1033,"y":516},{"type":"property","property":"getYScaled","fixed":1,"x":1243,"y":482},{"type":"lambda","fixed":1,"x":326,"y":4},{"type":"property","property":"xDomain","fixed":1,"x":498,"y":-11},{"type":"lambda","fixed":1,"x":952,"y":573},{"type":"lambda","fixed":1,"x":668,"y":-27},{"type":"property","property":"barPadding","fixed":1,"x":-248,"y":-101},{"type":"property","property":"xScale","fixed":1,"x":787,"y":0},{"type":"lambda","fixed":1,"x":1092,"y":96},{"type":"property","property":"getXScaled","fixed":1,"x":1233,"y":131},{"type":"lambda","fixed":1,"x":955,"y":-2},{"type":"lambda","fixed":1,"x":1378,"y":318}],"links":[{"source":1,"target":0},{"source":0,"target":2},{"source":2,"target":3},{"source":4,"target":3},{"source":2,"target":5},{"source":5,"target":6},{"source":6,"target":7},{"source":8,"target":7},{"source":6,"target":9},{"source":9,"target":10},{"source":10,"target":11},{"source":12,"target":11},{"source":4,"target":13},{"source":8,"target":13},{"source":13,"target":14},{"source":13,"target":15},{"source":6,"target":16},{"source":16,"target":17},{"source":16,"target":18},{"source":18,"target":19},{"source":20,"target":19},{"source":17,"target":21},{"source":15,"target":21},{"source":18,"target":22},{"source":14,"target":22},{"source":18,"target":23},{"source":24,"target":23},{"source":6,"target":25},{"source":25,"target":26},{"source":25,"target":27},{"source":27,"target":28},{"source":29,"target":28},{"source":27,"target":30},{"source":15,"target":30},{"source":27,"target":31},{"source":32,"target":31},{"source":6,"target":33},{"source":33,"target":34},{"source":10,"target":35},{"source":14,"target":35},{"source":37,"target":36},{"source":38,"target":36},{"source":36,"target":39},{"source":41,"target":40},{"source":42,"target":40},{"source":37,"target":40},{"source":40,"target":43},{"source":37,"target":44},{"source":45,"target":44},{"source":44,"target":46},{"source":37,"target":47},{"source":46,"target":47},{"source":48,"target":47},{"source":49,"target":47},{"source":47,"target":50},{"source":37,"target":51},{"source":50,"target":51},{"source":15,"target":51},{"source":51,"target":52},{"source":37,"target":53},{"source":52,"target":53},{"source":46,"target":53},{"source":53,"target":54},{"source":43,"target":55},{"source":39,"target":55},{"source":55,"target":56},{"source":26,"target":57},{"source":52,"target":57},{"source":56,"target":58},{"source":14,"target":58},{"source":59,"target":58},{"source":58,"target":60},{"source":37,"target":61},{"source":60,"target":61},{"source":39,"target":61},{"source":61,"target":62},{"source":17,"target":63},{"source":60,"target":63},{"source":34,"target":64},{"source":43,"target":64},{"source":62,"target":64},{"source":54,"target":64},{"source":60,"target":64},{"source":15,"target":64}],"scale":0.5332125839901604,"translate":[373.3250529749264,143.7733216449567]} |
// A force directed graph visualization module. | |
define(["d3", "model", "lodash"], function (d3, Model, _) { | |
// The constructor function, accepting default values. | |
return function ForceDirectedGraph(defaults) { | |
// Create a Model. | |
// This will serve as the public API for the visualization. | |
var model = Model({ | |
// Force directed layout parameters. | |
charge: -200, | |
linkDistance: 140, | |
gravity: 0.03, | |
// The color scale. | |
color: d3.scale.ordinal() | |
.domain(["property", "lambda"]) | |
.range(["#FFD1B5", "white"]) | |
}), | |
force = d3.layout.force(), | |
zoom = d3.behavior.zoom(), | |
// The size of nodes and arrows | |
nodeSize = 20, | |
arrowWidth = 8; | |
// Respond to zoom interactions. | |
zoom.on("zoom", function (){ | |
model.scale = zoom.scale(); | |
model.translate = zoom.translate(); | |
}); | |
// Call onTick each frame of the force directed layout. | |
force.on("tick", function(e) { onTick(e); }) | |
// This function gets reassigned later, each time new data loads. | |
function onTick(){} | |
// Stop propagation of drag events here so that both dragging nodes and panning are possible. | |
// Draws from http://stackoverflow.com/questions/17953106/why-does-d3-js-v3-break-my-force-graph-when-implementing-zooming-when-v2-doesnt/17976205#17976205 | |
force.drag().on("dragstart", function () { | |
d3.event.sourceEvent.stopPropagation(); | |
}); | |
// Fix node positions after the first time the user clicks and drags a node. | |
force.drag().on("dragend", function (d) { | |
// Stop the dragged node from moving. | |
d.fixed = true; | |
// Communicate this change to the outside world. | |
serializeState(); | |
}); | |
// Create the SVG element from the container DOM element. | |
model.when("container", function (container) { | |
model.svg = d3.select(container).append("svg").call(zoom); | |
}); | |
// Adjust the size of the SVG based on the `box` property. | |
model.when(["svg", "box"], function (svg, box) { | |
svg.attr("width", box.width).attr("height", box.height); | |
force.size([box.width, box.height]); | |
}); | |
// Create the SVG group that will contain the visualization. | |
model.when("svg", function (svg) { | |
model.g = svg.append("g"); | |
// Arrowhead setup. | |
// Draws from Mobile Patent Suits example: | |
// http://bl.ocks.org/mbostock/1153292 | |
svg.append("defs") | |
.append("marker") | |
.attr("id", "arrow") | |
.attr("orient", "auto") | |
.attr("preserveAspectRatio", "none") | |
// See also http://www.w3.org/TR/SVG/coords.html#ViewBoxAttribute | |
//.attr("viewBox", "0 -" + arrowWidth + " 10 " + (2 * arrowWidth)) | |
.attr("viewBox", "0 -5 10 10") | |
// See also http://www.w3.org/TR/SVG/painting.html#MarkerElementRefXAttribute | |
.attr("refX", 10) | |
.attr("refY", 0) | |
.attr("markerWidth", 10) | |
.attr("markerHeight", arrowWidth) | |
.append("path") | |
.attr("d", "M0,-5L10,0L0,5"); | |
}); | |
// These 3 groups exist for control of Z-ordering. | |
model.when("g", function (g) { | |
model.nodeG = g.append("g"); | |
model.linkG = g.append("g"); | |
model.arrowG = g.append("g"); | |
}); | |
// Update the force layout with configured properties. | |
model.when(["charge"], force.charge, force); | |
model.when(["linkDistance"], force.linkDistance, force); | |
model.when(["gravity"], force.gravity, force); | |
// Update zoom scale and translation. | |
model.when(["scale", "translate", "g"], function (scale, translate, g) { | |
// In the case the scale and translate were set externally, | |
if(zoom.scale() !== scale){ | |
// update the internal D3 zoom state. | |
zoom.scale(scale); | |
zoom.translate(translate); | |
} | |
// Transform the SVG group. | |
g.attr("transform", "translate(" + translate + ")scale(" + scale + ")"); | |
}); | |
// "state" represents the serialized state of the graph. | |
model.when("state", function(state){ | |
// Extract the scale and translate. | |
if(state.scale && model.scale !== state.scale){ | |
model.scale = state.scale; | |
} | |
if(state.translate && model.translate !== state.translate){ | |
model.translate = state.translate; | |
} | |
// Set the node and link data. | |
var newData = _.cloneDeep(state); | |
force.nodes(newData.nodes).links(newData.links).start(); | |
model.data = newData; | |
}); | |
// Update the serialized state. | |
model.when(["scale", "translate"], _.throttle(function(scale, translate){ | |
serializeState(); | |
}, 1000)); | |
// Sets model.state to expose the serialized state. | |
function serializeState(){ | |
var data = model.data, | |
scale = model.scale, | |
translate = model.translate; | |
model.state = { | |
nodes: data.nodes.map(function(node){ | |
return { | |
type: node.type, | |
property: node.property, | |
fixed: node.fixed, | |
// Keep size of JSON small, so it fits in a URL. | |
x: Math.round(node.x), | |
y: Math.round(node.y) | |
}; | |
}), | |
links: data.links.map(function(link){ | |
// Replaced link object references with indices for serialization. | |
return { | |
source: link.source.index, | |
target: link.target.index | |
}; | |
}), | |
scale: scale, | |
translate: translate | |
}; | |
} | |
model.when(["data", "color", "nodeG", "linkG", "arrowG"], | |
function(data, color, nodeG, linkG, arrowG){ | |
var node = nodeG.selectAll("g").data(data.nodes), | |
nodeEnter = node.enter().append("g").call(force.drag); | |
nodeEnter.append("rect").attr("class", "node") | |
.attr("y", -nodeSize) | |
.attr("height", nodeSize * 2) | |
.attr("rx", nodeSize) | |
.attr("ry", nodeSize); | |
nodeEnter.append("text").attr("class", "nodeLabel"); | |
node.select("g text") | |
// Use the property name for property nodes, and λ for lambda nodes. | |
.text(function(d) { | |
return (d.type === "property" ? d.property : "λ"); | |
}) | |
//Center text vertically. | |
.attr("dy", function(d) { | |
if(d.type === "lambda"){ | |
return "0.35em"; | |
} else { | |
return "0.3em"; | |
} | |
}) | |
// Compute rectancle sizes based on text labels. | |
.each(function (d) { | |
var circleWidth = nodeSize * 2, | |
textLength = this.getComputedTextLength(), | |
textWidth = textLength + nodeSize; | |
if(circleWidth > textWidth) { | |
d.isCircle = true; | |
d.rectX = -nodeSize; | |
d.rectWidth = circleWidth; | |
} else { | |
d.isCircle = false; | |
d.rectX = -(textLength + nodeSize) / 2; | |
d.rectWidth = textWidth; | |
d.textLength = textLength; | |
} | |
}); | |
node.select("g rect") | |
.attr("x", function(d) { return d.rectX; }) | |
.style("foo", function(d) { return "test"; }) | |
.attr("width", function(d) { return d.rectWidth; }) | |
.style("fill", function(d) { return color(d.type); }); | |
node.exit().remove(); | |
var link = linkG.selectAll(".link").data(data.links); | |
link.enter().append("line").attr("class", "link") | |
link.exit().remove(); | |
var arrow = arrowG.selectAll(".arrow").data(data.links); | |
arrow.enter().append("line") | |
.attr("class", "arrow") | |
.attr("marker-end", function(d) { return "url(#arrow)" }); | |
arrow.exit().remove(); | |
// Run a modified version of force directed layout | |
// to account for link direction going from left to right. | |
onTick = function(e) { | |
// Execute left-right constraints | |
var k = 1 * e.alpha; | |
force.links().forEach(function (link) { | |
var a = link.source, | |
b = link.target, | |
dx = b.x - a.x, | |
dy = b.y - a.y, | |
d = Math.sqrt(dx * dx + dy * dy), | |
x = (a.x + b.x) / 2; | |
if(!a.fixed){ | |
a.x += k * (x - d / 2 - a.x); | |
} | |
if(!b.fixed){ | |
b.x += k * (x + d / 2 - b.x); | |
} | |
}); | |
force.nodes().forEach(function (d) { | |
if(d.isCircle){ | |
d.leftX = d.rightX = d.x; | |
} else { | |
d.leftX = d.x - d.textLength / 2 + nodeSize / 2; | |
d.rightX = d.x + d.textLength / 2 - nodeSize / 2; | |
} | |
}); | |
link.call(edge); | |
arrow.call(edge); | |
node.attr("transform", function(d) { | |
return "translate(" + d.x + "," + d.y + ")"; | |
}); | |
}; | |
}); | |
// Sets the (x1, y1, x2, y2) line properties for graph edges. | |
function edge(selection){ | |
selection | |
.each(function (d) { | |
var sourceX, targetX, dy, dy, angle; | |
if( d.source.rightX < d.target.leftX ){ | |
sourceX = d.source.rightX; | |
targetX = d.target.leftX; | |
} else if( d.target.rightX < d.source.leftX ){ | |
targetX = d.target.rightX; | |
sourceX = d.source.leftX; | |
} else if (d.target.isCircle) { | |
targetX = sourceX = d.target.x; | |
} else if (d.source.isCircle) { | |
targetX = sourceX = d.source.x; | |
} else { | |
targetX = sourceX = (d.source.x + d.target.x) / 2; | |
} | |
dx = targetX - sourceX; | |
dy = d.target.y - d.source.y; | |
angle = Math.atan2(dx, dy); | |
d.sourceX = sourceX + Math.sin(angle) * nodeSize; | |
d.targetX = targetX - Math.sin(angle) * nodeSize; | |
d.sourceY = d.source.y + Math.cos(angle) * nodeSize; | |
d.targetY = d.target.y - Math.cos(angle) * nodeSize; | |
}) | |
.attr("x1", function(d) { return d.sourceX; }) | |
.attr("y1", function(d) { return d.sourceY; }) | |
.attr("x2", function(d) { return d.targetX; }) | |
.attr("y2", function(d) { return d.targetY; }); | |
} | |
model.set(defaults); | |
return model; | |
}; | |
}); |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<!-- Use RequireJS for module loading. --> | |
<script src="//cdnjs.cloudflare.com/ajax/libs/require.js/2.1.14/require.js"></script> | |
<!-- Configure RequireJS paths for third party libraries. --> | |
<script> | |
requirejs.config({ | |
paths: { | |
d3: "//d3js.org/d3.v3.min", | |
jquery: "//code.jquery.com/jquery-2.1.1.min", | |
lodash: "//cdnjs.cloudflare.com/ajax/libs/lodash.js/3.4.0/lodash.min", | |
async: "//cdnjs.cloudflare.com/ajax/libs/async/0.9.0/async", | |
crossfilter: "//cdnjs.cloudflare.com/ajax/libs/crossfilter/1.3.11/crossfilter.min" | |
} | |
}); | |
</script> | |
<!-- Include CSS that styles the visualization. --> | |
<link rel="stylesheet" href="styles.css"> | |
<title>Data Flow Diagram</title> | |
</head> | |
<body> | |
<!-- The visualization will be injected into this div. --> | |
<div id="container"></div> | |
<!-- Run the main program. --> | |
<script src="main.js"></script> | |
</body> | |
</html> |
require(["d3", "forceDirectedGraph", "lodash"], function (d3, ForceDirectedGraph, lodash) { | |
// Initialize the force directed graph. | |
var container = d3.select("#container").node(), | |
forceDirectedGraph = ForceDirectedGraph({ container: container }); | |
// Initialize zoom based on client size. | |
var scale = container.clientWidth * 1 / 800; | |
forceDirectedGraph.scale = scale; | |
forceDirectedGraph.translate = [ | |
container.clientWidth / 2 * (1 - scale), | |
container.clientHeight / 2 * (1 - scale) | |
]; | |
// Set up default data. | |
if(!location.hash){ | |
location.hash = '{"nodes":[{"type":"lambda","fixed":0,"x":442,"y":250},{"type":"property","property":"firstName","fixed":1,"x":290,"y":212},{"type":"property","property":"lastName","fixed":1,"x":293,"y":294},{"type":"property","property":"fullName","fixed":0,"x":581,"y":247}],"links":[{"source":1,"target":0},{"source":2,"target":0},{"source":0,"target":3}],"scale":1.938287710980903,"translate":[-360.71751731834274,-241.583180104211]}'; | |
} | |
// Update the fragment identifier in response to user interactions. | |
forceDirectedGraph.when(["state"], function(state){ | |
location.hash = JSON.stringify(state); | |
console.log(JSON.stringify(state)); | |
}); | |
// Sets the data on the graph visualization from the fragment identifier. | |
// See https://github.com/curran/screencasts/blob/gh-pages/navigation/examples/code/snapshot11/main.js | |
function navigate(){ | |
if(location.hash){ | |
var newState = JSON.parse(location.hash.substr(1)); | |
if(JSON.stringify(newState) !== JSON.stringify(forceDirectedGraph.state)){ | |
forceDirectedGraph.state = newState; | |
} | |
} | |
} | |
// Navigate once to the initial hash value. | |
navigate(); | |
// Navigate whenever the fragment identifier value changes. | |
window.addEventListener("hashchange", navigate); | |
// Sets the `box` model property | |
// based on the size of the container, | |
function computeBox(){ | |
forceDirectedGraph.box = { | |
width: container.clientWidth, | |
height: container.clientHeight | |
}; | |
} | |
// once to initialize `model.box`, and | |
computeBox(); | |
// whenever the browser window resizes in the future. | |
window.addEventListener("resize", computeBox); | |
}); |
// Implements key-value models with a functional reactive `when` operator. | |
// See also https://github.com/curran/model | |
define([], function (){ | |
// The constructor function, accepting default values. | |
return function Model(defaults){ | |
// The returned public API object. | |
var model = {}, | |
// The internal stored values for tracked properties. { property -> value } | |
values = {}, | |
// The listeners for each tracked property. { property -> [callback] } | |
listeners = {}, | |
// The set of tracked properties. { property -> true } | |
trackedProperties = {}; | |
// The functional reactive "when" operator. | |
// | |
// * `properties` An array of property names (can also be a single property string). | |
// * `callback` A callback function that is called: | |
// * with property values as arguments, ordered corresponding to the properties array, | |
// * only if all specified properties have values, | |
// * once for initialization, | |
// * whenever one or more specified properties change, | |
// * on the next tick of the JavaScript event loop after properties change, | |
// * only once as a result of one or more synchronous changes to dependency properties. | |
function when(properties, callback){ | |
// This function will trigger the callback to be invoked. | |
var triggerCallback = debounce(function (){ | |
var args = properties.map(function(property){ | |
return values[property]; | |
}); | |
if(allAreDefined(args)){ | |
callback.apply(null, args); | |
} | |
}); | |
// Handle either an array or a single string. | |
properties = (properties instanceof Array) ? properties : [properties]; | |
// Trigger the callback once for initialization. | |
triggerCallback(); | |
// Trigger the callback whenever specified properties change. | |
properties.forEach(function(property){ | |
on(property, triggerCallback); | |
}); | |
} | |
// Returns a debounced version of the given function. | |
// See http://underscorejs.org/#debounce | |
function debounce(callback){ | |
var queued = false; | |
return function () { | |
if(!queued){ | |
queued = true; | |
setTimeout(function () { | |
queued = false; | |
callback(); | |
}, 0); | |
} | |
}; | |
} | |
// Returns true if all elements of the given array are defined, false otherwise. | |
function allAreDefined(arr){ | |
return !arr.some(function (d) { | |
return typeof d === 'undefined' || d === null; | |
}); | |
} | |
// Adds a change listener for a given property with Backbone-like behavior. | |
// See http://backbonejs.org/#Events-on | |
function on(property, callback){ | |
getListeners(property).push(callback); | |
track(property); | |
}; | |
// Gets or creates the array of listener functions for a given property. | |
function getListeners(property){ | |
return listeners[property] || (listeners[property] = []); | |
} | |
// Tracks a property if it is not already tracked. | |
function track(property){ | |
if(!(property in trackedProperties)){ | |
trackedProperties[property] = true; | |
values[property] = model[property]; | |
Object.defineProperty(model, property, { | |
get: function () { return values[property]; }, | |
set: function(value) { | |
values[property] = value; | |
getListeners(property).forEach(function(callback){ | |
callback(value); | |
}); | |
} | |
}); | |
} | |
} | |
// Sets all of the given values on the model. | |
// Values is an object { property -> value }. | |
function set(values){ | |
for(property in values){ | |
model[property] = values[property]; | |
} | |
} | |
// Transfer defaults passed into the constructor to the model. | |
set(defaults); | |
// Expose the public API. | |
model.when = when; | |
model.on = on; | |
model.set = set | |
return model; | |
} | |
}); |
/* Make the visualization container fill the page. */ | |
#container { | |
position: fixed; | |
left: 0px; | |
right: 0px; | |
top: 0px; | |
bottom: 0px; | |
} | |
/* Style the nodes of the graph. */ | |
.node { | |
stroke: black; | |
stroke-width: 1.5; | |
} | |
.nodeLabel { | |
font-size: 2em; | |
/* Center text horizontally */ | |
text-anchor: middle; | |
} | |
/* Style the links of the graph. */ | |
.link { | |
stroke: black; | |
} | |
/* Set the arrowhead size. */ | |
.arrow { | |
stroke-width: 1.5px; | |
} |