You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ka...@apache.org on 2021/04/07 22:04:24 UTC

[airflow] branch master updated: Refactor/Cleanup Presentation of Graph Task and Path Highlighting (#15257)

This is an automated email from the ASF dual-hosted git repository.

kaxilnaik pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/master by this push:
     new 47cbff9  Refactor/Cleanup Presentation of Graph Task and Path Highlighting (#15257)
47cbff9 is described below

commit 47cbff9ce06a927c318ec77b32d79876b6828071
Author: Ryan Hamilton <ry...@ryanahamilton.com>
AuthorDate: Wed Apr 7 18:03:59 2021 -0400

    Refactor/Cleanup Presentation of Graph Task and Path Highlighting (#15257)
    
    This is a collection of related updates to improve the overall user experience of the Graph view.
    
    I tested these updates across varying DAG shapes/sizes including Task Groups. This testing also covered the hovering/clicking of the task status legend and the search filter functionality.
    
    ### General
    - Simplifies JavaScript by removing the redundant inline styling for highlighting tasks and paths between them. This is accomplished by pairing a `data-highlight` attribute with CSS keyed off of it.
    - I tried not to go overboard (but couldn't help myself in a few places) on the syntax cleanup as that will be handled in the migration to an external JavaScript file for #14115.
    - Changes the "edge" (lines between tasks) curve to be smooth instead of angled straight lines. Single, smooth lines are easier for eyes to follow the path of than multiple jointed lines. This was accomplished by utilizing the `d3-shape` library.
    
    
    - Slightly increases the vertical node separation (`nodeSep`) to make the graphs feel a little less crowded. This also reduces the amount that the task nodes overlap with the edges.
    
    - Fixed an unreported bug when you hover or click on "no_status" in the status legend at the top, the correlating tasks would not highlight.
    
    
    - Updated the Graph UI screenshot in the docs to reflect the changes in this PR.
    
    ### Task and Path highlighting
    - Removes the varying stroke (border) weights of tasks when highlighting/hovering. This interaction isn't necessary given the non-highlighted tasks fade out. It (subjectively) makes for a smoother transition for only the stroke color to change instead of the weight as well.
    - Improves the styling of the "path highlighting". Instead of styling the downstream, hovered, and upstream tasks with 3 different border colors, I've given them all the same "highlighted" color and have also given the edges that connect them a similar styling. This simplifies the pattern and visually highlights the actual path between the tasks. The upstream and downstream directions can still easily be deciphered by the edge arrows.
    
    
    ### Fixes tooltip jank
    
    - Prevents the jumping of the tooltip position that occurs when moving your cursor within the task node
    - Positions the tooltip slightly higher so it no longer overlaps with the task node's border
    - Adds a very slight opacity to the tooltip background since it can cover up relevant paths between the hovered task node
---
 LICENSE                                  |   1 +
 airflow/www/package.json                 |   1 +
 airflow/www/static/css/graph.css         |  42 +++-
 airflow/www/static/css/main.css          |  10 +-
 airflow/www/templates/airflow/graph.html | 341 ++++++++++++++-----------------
 airflow/www/webpack.config.js            |   4 +
 airflow/www/yarn.lock                    |  12 ++
 docs/apache-airflow/img/graph.png        | Bin 225347 -> 110218 bytes
 licenses/LICENSE-d3-shape.txt            |  27 +++
 setup.cfg                                |   1 +
 10 files changed, 249 insertions(+), 190 deletions(-)

diff --git a/LICENSE b/LICENSE
index d6ff734..651ef63 100644
--- a/LICENSE
+++ b/LICENSE
@@ -250,3 +250,4 @@ The following components are provided under the BSD 3-Clause license. See projec
 The text of each license is also included at licenses/LICENSE-[project].txt.
 
     (BSD 3 License) d3 v5.15.0 (https://d3js.org)
+    (BSD 3 License) d3-shape v2.1.0 (https://github.com/d3/d3-shape)
diff --git a/airflow/www/package.json b/airflow/www/package.json
index c2ee060..8429239 100644
--- a/airflow/www/package.json
+++ b/airflow/www/package.json
@@ -60,6 +60,7 @@
     "bootstrap-3-typeahead": "^4.0.2",
     "codemirror": "^5.59.1",
     "d3": "^3.4.4",
+    "d3-shape": "^2.1.0",
     "d3-tip": "^0.9.1",
     "dagre-d3": "^0.6.4",
     "datatables.net": "^1.10.23",
diff --git a/airflow/www/static/css/graph.css b/airflow/www/static/css/graph.css
index 3b59078..a260fa1 100644
--- a/airflow/www/static/css/graph.css
+++ b/airflow/www/static/css/graph.css
@@ -25,6 +25,11 @@ svg {
   stroke: #51504f;
   stroke-width: 1px;
   fill: #fff;
+  transition: stroke 0.2s ease-in-out, opacity 0.2s ease-in-out;
+}
+
+.node rect[data-highlight="highlight"] {
+  stroke: #017cee !important;
 }
 
 .edgeLabel rect {
@@ -34,6 +39,28 @@ svg {
 .edgePath {
   stroke: #51504f;
   stroke-width: 1px;
+  fill: none;
+  transition: stroke 0.2s ease-in-out, opacity 0.2s ease-in-out;
+}
+
+.edgePath[data-highlight="fade"] {
+  opacity: 0.2 !important;
+}
+
+.edgePath[data-highlight="highlight"] {
+  stroke: #017cee;
+}
+
+.edgePath .arrowhead {
+  stroke: none !important;
+  fill: #51504f;
+  stroke-width: 0 !important;
+  transition: fill 0.2s ease-in-out, opacity 0.2s ease-in-out;
+}
+
+.edgePath[data-highlight="highlight"] .arrowhead {
+  stroke: #017cee;
+  fill: #017cee;
 }
 
 g.cluster rect {
@@ -44,21 +71,30 @@ g.cluster rect {
   opacity: 0.5;
 }
 
+g.node {
+  transition: opacity 0.2s ease-in-out;
+}
+
+g.node[data-highlight="fade"] {
+  opacity: 0.2 !important;
+}
+
 g.node rect {
   stroke: #fff;
-  stroke-width: 3px;
+  stroke-width: 1.5px;
   cursor: pointer;
 }
 
 g.node circle {
   stroke: #51504f;
-  stroke-width: 3px;
+  stroke-width: 1.5px;
   cursor: pointer;
 }
 
 g.node .label {
-  font-size: inherit;
+  font-size: 0.7em;
   font-weight: normal;
+  pointer-events: none;
 }
 
 g.node text {
diff --git a/airflow/www/static/css/main.css b/airflow/www/static/css/main.css
index 088b54f..e946d2a 100644
--- a/airflow/www/static/css/main.css
+++ b/airflow/www/static/css/main.css
@@ -160,12 +160,12 @@ span.skipped {
 }
 
 .d3-tip {
-  background: #000;
+  background: rgba(0, 0, 0, 0.85);
   color: #fff;
-  border: solid;
-  border-width: 1px;
+  border: 0;
   border-radius: 5px;
   padding: 10px;
+  margin-top: -4px;
 }
 
 input#execution_date {
@@ -271,6 +271,10 @@ body div.panel {
   font-size: 11px;
 }
 
+.legend-item--interactive {
+  cursor: pointer;
+}
+
 .legend-item--no-border {
   border-color: transparent;
   padding: 0;
diff --git a/airflow/www/templates/airflow/graph.html b/airflow/www/templates/airflow/graph.html
index defe084..389da7c 100644
--- a/airflow/www/templates/airflow/graph.html
+++ b/airflow/www/templates/airflow/graph.html
@@ -85,8 +85,8 @@
         {{ op.task_type }}</span>{% endfor %}
     </div>
     <div>
-      {% for state, state_color in state_color_mapping.items() %}<span class="legend-item js-state-legend-item" data-state="{{state}}" style="border-color: {{state_color}};">
-        {{state}}</span>{% endfor %}<span class="legend-item legend-item--no-border js-state-legend-item" data-state="no_status" style="border-color:white;">no_status</span>
+      {% for state, state_color in state_color_mapping.items() %}<span class="legend-item legend-item--interactive js-state-legend-item" data-state="{{state}}" style="border-color: {{state_color}};">
+        {{state}}</span>{% endfor %}<span class="legend-item legend-item--interactive legend-item--no-border js-state-legend-item" data-state="no_status" style="border-color:white;">no_status</span>
     </div>
   </div>
   <div id="error" style="display: none; margin-top: 10px;" class="alert alert-danger" role="alert">
@@ -118,16 +118,10 @@
   {{ super() }}
   <script src="{{ url_for_asset('d3.min.js') }}"></script>
   <script src="{{ url_for_asset('dagre-d3.min.js') }}"></script>
+  <script src="{{ url_for_asset('d3-shape.min.js') }}"></script>
   <script src="{{ url_for_asset('d3-tip.js') }}"></script>
   <script src="{{ url_for_asset('taskInstances.js') }}"></script>
   <script>
-
-      var highlight_color = "#000";
-      var upstream_color = "#2020a0";
-      var downstream_color = "#0000ff";
-      var initialStrokeWidth = '3px';
-      var highlightStrokeWidth = '5px';
-
       var nodes = {{ nodes|tojson }};
       var edges = {{ edges|tojson }};
       var execution_date = "{{ execution_date }}";
@@ -165,7 +159,7 @@
       // Preparation of DagreD3 data structures
       // "compound" is set to true to make use of clusters to display TaskGroup.
       var g = new dagreD3.graphlib.Graph({compound: true}).setGraph({
-          nodesep: 15,
+          nodesep: 30,
           ranksep: 15,
           rankdir: arrange,
         })
@@ -215,32 +209,30 @@
           }
         });
 
-        d3.selectAll("g.node").on("mouseover", function (d) {
-          d3.select(this).selectAll("rect").style("stroke", highlight_color);
-          highlight_nodes(g.predecessors(d), upstream_color, highlightStrokeWidth);
-          highlight_nodes(g.successors(d), downstream_color, highlightStrokeWidth)
-          adjacent_node_names = [d, ...g.predecessors(d), ...g.successors(d)]
+        d3.selectAll('g.node').on('mouseover', function (d) {
+          d3.select(this).selectAll('rect').attr('data-highlight', 'highlight');
+          highlightNodes(g.predecessors(d));
+          highlightNodes(g.successors(d));
+          adjacent_node_names = [d, ...g.predecessors(d), ...g.successors(d)];
+
           d3.selectAll("g.nodes g.node")
             .filter(x => !adjacent_node_names.includes(x))
-            .style("opacity", 0.2);
-          adjacent_edges = g.nodeEdges(d)
-          d3.selectAll("g.edgePath")[0]
-            .filter(x => !adjacent_edges.includes(x.__data__))
-            .forEach(function (x) {
-              d3.select(x).style('opacity', .2)
-            })
+            .attr('data-highlight', 'fade');
+
+          d3.selectAll("g.edgePath")[0].forEach(function (x) {
+            var val = g.nodeEdges(d).includes(x.__data__) ? 'highlight' : 'fade';
+            d3.select(x).attr('data-highlight', val);
+          });
         });
 
-        d3.selectAll("g.node").on("mouseout", function (d) {
-          d3.select(this).selectAll("rect,circle").style("stroke", null);
-          highlight_nodes(g.predecessors(d), null, initialStrokeWidth)
-          highlight_nodes(g.successors(d), null, initialStrokeWidth)
-          d3.selectAll("g.node")
-            .style("opacity", 1);
-          d3.selectAll("g.node rect")
-            .style("stroke-width", initialStrokeWidth);
-          d3.selectAll("g.edgePath")
-            .style("opacity", 1);
+        d3.selectAll('g.node').on('mouseout', function (d) {
+          d3.select(this).selectAll('rect, circle').attr('data-highlight', null);
+          unHighlightNodes(g.predecessors(d))
+          unHighlightNodes(g.successors(d))
+          d3.selectAll('g.node')
+            .attr('data-highlight', null);
+          d3.selectAll('g.edgePath')
+            .attr('data-highlight', null);
           localStorage.removeItem(focused_group_key(dag_id));
         });
         updateNodesStates(task_instances);
@@ -277,44 +269,49 @@
         zoom.event(innerSvg);
       }
 
-      function highlight_nodes(nodes, color, stroke_width) {
-          nodes.forEach (function (nodeid) {
-              const my_node = g.node(nodeid).elem
-              d3.select(my_node)
-                  .selectAll("rect,circle")
-                  .style("stroke", color)
-                  .style("stroke-width", stroke_width) ;
-          })
+      function highlightNodes(nodes) {
+        nodes.forEach (function (nodeid) {
+          const my_node = g.node(nodeid).elem
+          d3.select(my_node)
+            .selectAll('rect, circle')
+            .attr('data-highlight', 'highlight');
+        });
       }
 
-      d3.selectAll('.js-state-legend-item')
-          .style("cursor", "pointer")
-          .on("mouseover", function(){
-              if(!stateIsSet()){
-                  state = $(this).data('state');
-                  focusState(state);
-              }
-          })
-          .on("mouseout", function(){
-              if(!stateIsSet()){
-                  clearFocus();
-              }
-          });
+      function unHighlightNodes(nodes) {
+        nodes.forEach (function (nodeid) {
+          const my_node = g.node(nodeid).elem
+          d3.select(my_node)
+            .selectAll('rect, circle')
+            .attr('data-highlight', null);
+        });
+      }
 
       d3.selectAll('.js-state-legend-item')
-          .on("click", function(){
-              state = $(this).data('state');
-              color = d3.select(this).style("border-color");
-
-              if (!stateFocusMap[state]){
-                  clearFocus();
-                  focusState(state, this, color);
-                  setFocusMap(state);
-              } else {
-                  clearFocus();
-                  setFocusMap();
-              }
-          });
+        .on('mouseover', function() {
+          if (!stateIsSet()) {
+            state = $(this).data('state');
+            focusState(state);
+          }
+        })
+        .on('mouseout', function() {
+          if (!stateIsSet()) {
+            clearFocus();
+          }
+        });
+
+      d3.selectAll('.js-state-legend-item').on('click', function() {
+        state = $(this).data('state');
+
+        clearFocus();
+        if (!stateFocusMap[state]) {
+          color = d3.select(this).style('border-color');
+          focusState(state, this, color);
+          setFocusMap(state);
+        } else {
+          setFocusMap();
+        }
+      });
 
       // Returns true if a node's id or its children's id matches search_text
       function node_matches(node_id, search_text) {
@@ -333,49 +330,30 @@
       }
 
       d3.select("#searchbox").on("keyup", function() {
-          var s = document.getElementById("searchbox").value;
+          var s = document.getElementById('searchbox').value;
 
-          if(s == "")
-            return;
+          if (s == '') return;
 
           var match = null;
 
-          if (stateIsSet()){
-              clearFocus();
-              setFocusMap();
+          if (stateIsSet()) {
+            clearFocus();
+            setFocusMap();
           }
 
-          d3.selectAll("g.nodes g.node").filter(function(d, i){
-              if (s==""){
-                  d3.select("g.edgePaths")
-                      .transition().duration(duration)
-                      .style("opacity", 1);
-                  d3.select(this)
-                      .transition().duration(duration)
-                      .style("opacity", 1)
-                      .selectAll("rect")
-                      .style("stroke-width", initialStrokeWidth);
-              }
-              else{
-                  d3.select("g.edgePaths")
-                      .transition().duration(duration)
-                      .style("opacity", 0.2);
-                  if (node_matches(d, s)) {
-                      if (!match)
-                          match = this;
-                      d3.select(this)
-                          .transition().duration(duration)
-                          .style("opacity", 1)
-                          .selectAll("rect")
-                          .style("stroke-width", highlightStrokeWidth);
-                  } else {
-                      d3.select(this)
-                          .transition()
-                          .style("opacity", 0.2).duration(duration)
-                          .selectAll("rect")
-                          .style("stroke-width", initialStrokeWidth);
-                  }
+          d3.selectAll('g.nodes g.node').filter(function(d, i){
+            if (s == '') {
+              d3.select('g.edgePaths').attr('data-highlight', null);
+              d3.select(this).attr('data-highlight', null);
+            } else {
+              d3.select('g.edgePaths').attr('data-highlight', 'fade');
+              if (node_matches(d, s)) {
+                if (!match) match = this;
+                d3.select(this).attr('data-highlight', null);
+              } else {
+                d3.select(this).attr('data-highlight', 'fade');
               }
+            }
           });
 
           // This moves the matched node to the center of the graph area
@@ -397,56 +375,45 @@
           }
       });
 
-      function clearFocus(){
-          d3.selectAll("g.node")
-              .transition(duration)
-              .style("opacity", 1);
-          d3.selectAll("g.node rect")
-              .transition(duration)
-              .style("stroke-width", initialStrokeWidth);
-          d3.select("g.edgePaths")
-              .transition().duration(duration)
-              .style("opacity", 1);
-          d3.selectAll('.js-state-legend-item')
-              .style("background-color", null);
-
-          localStorage.removeItem(focused_group_key(dag_id));
+      function clearFocus() {
+        d3.selectAll('g.node')
+          .attr('data-highlight', null);
+        d3.select('g.edgePaths')
+          .attr('data-highlight', null);
+        d3.selectAll('.js-state-legend-item')
+          .style('background-color', null);
+        localStorage.removeItem(focused_group_key(dag_id));
       }
 
-      function focusState(state, node, color){
-          d3.selectAll("g.node")
-              .transition(duration)
-              .style("opacity", 0.2);
-          d3.selectAll(`g.node.${state}`)
-              .transition(duration)
-              .style("opacity", 1);
-          d3.selectAll(`g.node.${state} rect`)
-              .transition(duration)
-              .style("stroke-width", highlightStrokeWidth)
-              .style("opacity", 1);
-          d3.select("g.edgePaths")
-              .transition().duration(duration)
-              .style("opacity", 0.2);
-          d3.select(node)
-              .style("background-color", color);
+      function focusState(state, node, color) {
+        d3.selectAll('g.node')
+          .attr('data-highlight', 'fade');
+        d3.selectAll(`g.node.${state}`)
+          .attr('data-highlight', null);
+        d3.selectAll(`g.node.${state} rect`)
+          .attr('data-highlight', null);
+        d3.select('g.edgePaths')
+          .attr('data-highlight', 'fade');
+        d3.select(node)
+          .style('background-color', color);
       }
 
-      function setFocusMap(state){
-          for (var key in stateFocusMap){
-                  stateFocusMap[key] = false;
-          }
-          if(state != null){
-              stateFocusMap[state] = true;
-          }
+      function setFocusMap(state) {
+        for (var key in stateFocusMap) {
+          stateFocusMap[key] = false;
+        }
+        if (state != null) {
+          stateFocusMap[state] = true;
+        }
       }
 
-      function stateIsSet(){
-          for (var key in stateFocusMap){
-              if (stateFocusMap[key]){
-                  return true
-              }
+      function stateIsSet() {
+        for (var key in stateFocusMap) {
+          if (stateFocusMap[key]) {
+            return true;
           }
-          return false
+        }
+        return false;
       }
 
       function handleRefresh() {
@@ -608,18 +575,18 @@
         node = g.node(node_id)
 
         if (node.children == undefined) {
-          if(node_id in tis)
-            return tis[node_id].state
-
-          return "no_status"
+          if (node_id in tis) {
+            return tis[node_id].state || 'no_status';
+          }
+          return 'no_status';
         }
-        var children = get_children_ids(node)
+        var children = get_children_ids(node);
 
-        children_states = new Set()
+        children_states = new Set();
         children.forEach(function(task_id) {
           if (task_id in tis) {
-            var state = tis[task_id].state
-            children_states.add(state == null ? "no_status" : state)
+            var state = tis[task_id].state;
+            children_states.add(state == null ? "no_status" : state);
           }
         })
 
@@ -676,16 +643,14 @@
             zoom.event(innerSvg.transition().duration(duration));
 
             const children = new Set(g.children(node_id))
-            // Change opacity to highlight the focused group.
-            d3.selectAll("g.nodes g.node").filter(function(d, i){
+            // Set data attr to highlight the focused group (via CSS).
+            d3.selectAll('g.nodes g.node').filter(function(d, i){
               if (d == node_id || children.has(d)) {
-                  d3.select(this)
-                      .transition().duration(duration)
-                      .style("opacity", 1)
+                d3.select(this)
+                  .attr('data-highlight', null);
               } else {
-                  d3.select(this)
-                      .transition()
-                      .style("opacity", 0.2).duration(duration)
+                d3.select(this)
+                  .attr('data-highlight', 'fade');
               }
             });
 
@@ -718,40 +683,44 @@
           source_id = map_task_to_node.get(edge.source_id)
           target_id = map_task_to_node.get(edge.target_id)
           if(source_id != target_id && !g.hasEdge(source_id, target_id))
-            g.setEdge(source_id, target_id)
+            g.setEdge(source_id, target_id, {
+              curve: d3.curveBasis,
+              arrowheadClass: 'arrowhead',
+            });
         })
 
         g.edges().forEach(function (edge) {
           // Remove edges that were associated with the expanded group node..
-          if(node_id == edge.v || node_id == edge.w)
-            g.removeEdge(edge.v, edge.w)
-        })
+          if (node_id == edge.v || node_id == edge.w) {
+            g.removeEdge(edge.v, edge.w);
+          }
+        });
 
-        draw()
+        draw();
 
         if (focus) {
           focus_group(node_id);
         }
 
-        save_expanded_group(node_id)
-    }
+        save_expanded_group(node_id);
+      }
 
-    // Remove the node with this node_id from g.
-    function remove_node(node_id) {
-      if (g.hasNode(node_id)) {
-          node = g.node(node_id)
-          if(node.children != undefined) {
-            // If the child is an expanded group node, remove children too.
-            node.children.forEach(function (child) {
-              remove_node(child.id);
-            })
-          }
+      // Remove the node with this node_id from g.
+      function remove_node(node_id) {
+        if (g.hasNode(node_id)) {
+            node = g.node(node_id)
+            if(node.children != undefined) {
+              // If the child is an expanded group node, remove children too.
+              node.children.forEach(function (child) {
+                remove_node(child.id);
+              })
+            }
+        }
+        g.removeNode(node_id);
       }
-      g.removeNode(node_id)
-    }
 
-    // Collapse the children of the given group node.
-    function collapse_group(node_id, node) {
+      // Collapse the children of the given group node.
+      function collapse_group(node_id, node) {
         // Remove children nodes
         node.children.forEach(function(child) {
           remove_node(child.id)
@@ -766,8 +735,12 @@
         edges.forEach(function(edge) {
           source_id = map_task_to_node.get(edge.source_id)
           target_id = map_task_to_node.get(edge.target_id)
-          if(source_id != target_id && !g.hasEdge(source_id, target_id))
-            g.setEdge(source_id, target_id)
+          if (source_id != target_id && !g.hasEdge(source_id, target_id)) {
+            g.setEdge(source_id, target_id, {
+              curve: d3.curveBasis,
+              arrowheadClass: 'arrowhead',
+            });
+          }
         })
 
         draw()
diff --git a/airflow/www/webpack.config.js b/airflow/www/webpack.config.js
index ce287d1..71dc63b 100644
--- a/airflow/www/webpack.config.js
+++ b/airflow/www/webpack.config.js
@@ -165,6 +165,10 @@ const config = {
           flatten: true,
         },
         {
+          from: 'node_modules/d3-shape/dist/*.min.*',
+          flatten: true,
+        },
+        {
           from: 'node_modules/d3-tip/dist/index.js',
           to: 'd3-tip.js',
           flatten: true,
diff --git a/airflow/www/yarn.lock b/airflow/www/yarn.lock
index 119d9fa..cf96ef8 100644
--- a/airflow/www/yarn.lock
+++ b/airflow/www/yarn.lock
@@ -2162,6 +2162,11 @@ d3-path@1:
   resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
   integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
 
+"d3-path@1 - 2":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-2.0.0.tgz#55d86ac131a0548adae241eebfb56b4582dd09d8"
+  integrity sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==
+
 d3-polygon@1:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
@@ -2209,6 +2214,13 @@ d3-shape@1:
   dependencies:
     d3-path "1"
 
+d3-shape@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-2.1.0.tgz#3b6a82ccafbc45de55b57fcf956c584ded3b666f"
+  integrity sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==
+  dependencies:
+    d3-path "1 - 2"
+
 d3-time-format@2:
   version "2.2.3"
   resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.2.3.tgz#0c9a12ee28342b2037e5ea1cf0b9eb4dd75f29cb"
diff --git a/docs/apache-airflow/img/graph.png b/docs/apache-airflow/img/graph.png
index 23279f8..11ca51b 100644
Binary files a/docs/apache-airflow/img/graph.png and b/docs/apache-airflow/img/graph.png differ
diff --git a/licenses/LICENSE-d3-shape.txt b/licenses/LICENSE-d3-shape.txt
new file mode 100644
index 0000000..4f0b022
--- /dev/null
+++ b/licenses/LICENSE-d3-shape.txt
@@ -0,0 +1,27 @@
+Copyright 2010-2015 Mike Bostock
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the author nor the names of contributors may be used to
+  endorse or promote products derived from this software without specific prior
+  written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/setup.cfg b/setup.cfg
index 1dcdef9..dbe2703 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -30,6 +30,7 @@ license_files =
 # Start of licenses generated automatically
    licenses/LICENSE-bootstrap.txt
    licenses/LICENSE-bootstrap3-typeahead.txt
+   licenses/LICENSE-d3-shape.txt
    licenses/LICENSE-d3-tip.txt
    licenses/LICENSE-d3js.txt
    licenses/LICENSE-dagre-d3.txt