<!DOCTYPE html>
<html lang="en">
<head>
<title>Interdependency Graph</title>
<meta charset="utf-8">
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.11.0/dist/cytoscape.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.4/dagre.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.2.2/cytoscape-dagre.min.js"></script>
<script type="application/javascript">
  const renderGraph = (data) => {
    const config = {
      container: document.getElementById('cy'),
      elements: [],
      autounselectify: true,

      layout: {
        name: 'dagre',
        ranker: 'tight-tree',
        nodeSep: 10,
        rankSep: 400,
        padding: 10
      },
    
      style: [
        {
          selector: '.hidden',
          style: {
            'display': 'none'
          }
        },
        {
          selector: 'node',
          style: {
            'background-color': '#fff',
            'border-color': '#333',
            'border-width': '1px',
            'height': 'label',
            'label': 'data(label)',
            'padding': '8px',
            'shape': 'round-rectangle',
            'text-halign': 'center',
            'text-valign': 'center',
            'text-wrap': 'wrap',
            'width': 'label'
          }
        },
        {
          selector: 'node.internal',
          style: {
            'background-color': '#7f7'
          }
        },
        {
          selector: 'node.internalbinary',
          style: {
            'background-color': '#fb7'
          }
        },
        {
          selector: 'node.collapsed',
          style: {
            'background-color': '#b7f'
          }
        },
        {
          selector: 'node.search',
          style: {
            'background-color': '#ff7',
            'border-width': '6px',
            'display': 'element'
          }
        },
        {
          selector: 'node.highlight',
          style: {
            'background-color': '#fff',
            'border-width': '6px',
            'display': 'element'
          }
        },
        {
          selector: 'node.highlight.in',
          style: {
            'border-color': '#7bf'
          }
        },
        {
          selector: 'node.highlight.out',
          style: {
            'border-color': '#f77'
          }
        },
        {
          selector: 'node.highlight.source',
          style: {
            'border-color': '#f77'
          }
        },
        {
          selector: 'node.highlight.internal',
          style: {
            'background-color': '#7f7'
          }
        },
        {
          selector: 'node.highlight.internalbinary',
          style: {
            'background-color': '#fb7'
          }
        },
        {
          selector: 'node.highlight.collapsed',
          style: {
            'background-color': '#b7f'
          }
        },
        {
          selector: 'node.highlight.search',
          style: {
            'background-color': '#ff7'
          }
        },
        {
          selector: 'edge',
          style: {
            'curve-style': 'bezier',
            'label': 'data(label)',
            'line-color': '#333',
            'target-arrow-color': '#333',
            'target-arrow-shape': 'triangle',
            'width': '1.5px'
          }
        },
        {
          selector: 'edge.highlight',
          style: {
            'display': 'element',
            'width': '6px'
          }
        },
        {
          selector: 'edge.highlight.in',
          style: {
            'line-color': '#7bf',
            'target-arrow-color': '#7bf'
          }
        },
        {
          selector: 'edge.highlight.out',
          style: {
            'line-color': '#f77',
            'target-arrow-color': '#f77'
          }
        }
      ]
    }

    // Add the nodes
    for (const pkg of Object.keys(data)) {
      config.elements.push({
        data: {
          id: pkg,
          label: `${data[pkg].name}\n${data[pkg].version}`
        },
        classes: data[pkg].type
      })
    }

    // Add the edges
    for (const pkg of Object.keys(data)) {
      for (const dep of data[pkg].deps) {
        const dest = `${dep.name}:${dep.version}`
        const edge = {
          data: {
            id: `${pkg}:${dest}`,
            source: pkg,
            target: dest,
            label: dep.label || ''
          }
        }
        config.elements.push(edge)
      }
    }

    const cy = cytoscape(config)

    cy.on('mouseover', 'node', event => {
      const element = event.target
      if (element.hasClass('pinned')) { return }

      element.addClass('highlight source')
      element.outgoers().addClass('highlight out')
      element.incomers().addClass('highlight in')
    })

    cy.on('mouseout', 'node', event => {
      const element = event.target
      if (element.hasClass('pinned')) { return }

      element.removeClass('source')
      if (!element.hasClass('in') && !element.hasClass('out')) {
        element.removeClass('highlight')
      }

      element.outgoers().forEach(e => {
        e.removeClass('out')
        if (!e.hasClass('in') && !e.hasClass('source')) {
          e.removeClass('highlight')
        }
      })
      
      element.incomers().forEach(e => {
        e.removeClass('in')
        if (!e.hasClass('out') && !e.hasClass('source')) {
          e.removeClass('highlight')
        }
      })
    })

    cy.on('cxttap', 'node', event => {
      const element = event.target
      if (!element.hasClass('pinned')) {
        element.addClass('pinned')

      } else {
        element.removeClass('pinned')
      }
    })

    document.addEventListener('keydown', event => {
      if (document.activeElement.id === 'search') { return }

      if (event.key === '-') {
        cy.nodes('.internal').forEach(node => {
          if (!node.hasClass('hidden')) {
            triggerCollapse(cy, node, true)
          }
        })
      } else if (event.key === '=') {
        cy.nodes('.internal').forEach(node => {
          triggerCollapse(cy, node, false)
        })
      }
    })

    let searchTerm = ''
    document.getElementById('search').addEventListener('input', event => {
      const newValue = event.target.value
      if (searchTerm !== newValue) {
        searchTerm = newValue
        cy.nodes().removeClass('search')
        if (searchTerm.length > 0) {
          const matches = cy.nodes(`[label *= '${searchTerm}']`)
          matches.addClass('search')
          document.getElementById('matches').innerText = `Matches: ${matches.length}`
        } else {
          document.getElementById('matches').innerText = ''
        }
      }
    })

    cy.on('tap', 'node', event => {
      const element = event.target
      const collapse = !element.hasClass('collapsed')
      triggerCollapse(cy, element, collapse)
      element.emit('mouseout')
      element.emit('mouseover')
    })
  }

  const triggerCollapse = (cy, element, collapse) => {
    if (element.outgoers().length === 0) { return }

    if (collapse) {
      element.addClass('collapsed')
    } else {
      element.removeClass('collapsed')
    }

    if (collapse) {
      element.outgoers('edge').addClass('hidden')
      const orphans = cy.filter(e => {
        return e.isNode() &&
          !e.hasClass('internal') &&
          !e.incomers('edge').some(g => !g.hasClass('hidden'))
      })
      orphans.forEach(o => {
        o.addClass('hidden')
        o.successors().addClass('hidden') // no-op when only one tier of external nodes are present
      })
    } else {
      element.outgoers().removeClass('hidden')
    }
  }
</script>
<style>
  body {
    margin: 10 auto;
    color: #333;
    font-weight: 300;
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serf;
    pointer-events: none;
  }

  h1 {
    font-size: 3em;
    font-weight: 300;
    z-index: -2;
  }

  #cy {
    width: 100%;
    height: 100%;
    position: absolute;
    left: 0;
    top: 0;
    z-index: -1;
    pointer-events: all;
  }

  .panel {
    display: inline-block;
  }

  .panel div {
    margin: 4px auto;
  }

  .panel input {
    pointer-events: all;
  }
</style>
</head>
<body>
<div class="panel">
  <h1>Dependency Graph</h1>
  <label for="search">Search:</label>
  <input id="search" type="search" autocomplete="off" size="64" />
  <div id="matches"></div>
</div>
<div id="cy"></div>
<script type="application/javascript">
  const params = new URLSearchParams(window.location.search);
  const src = params.get("data") || "data.js";
  const script = document.createElement("script");
  script.src = src;
  script.async = false;
  script.addEventListener("load", () => renderGraph(data));
  script.addEventListener("error", e => {
    const dest = document.getElementsByClassName("panel")[0];
    dest.innerText = `Failed to load ${src}`;
  });
  document.head.appendChild(script);
</script>
</body>
</html>
