block by timelyportfolio dd6fb0e58dcc19158eff

dd6fb0e58dcc19158eff

Full Screen

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

index.html

<!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>

barChartFlow.json

{"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]}

forceDirectedGraph.js

// 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;
  };
});

main.js

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);
  
});

model.js

// 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;
  }
});

styles.css

/* 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;
}