fdesjardins/express-routes-visualizer

View on GitHub
lib/template.html

Summary

Maintainability
Test Coverage
<!DOCTYPE html>
<meta charset="utf-8">
<head>
  <title>{{title}}</title>
  <style>
    html {
      box-sizing: border-box;
    }
    *,
    *::before,
    *::after {
      box-sizing: inherit;
    }
    body {
      margin: 0;
      background-color: {{styles.colors.background}};
    }
    .node {
      cursor: pointer;
    }
    .node circle {
      fill: {{styles.colors.nodeFill}};
      stroke: {{styles.colors.nodeStroke}};
      stroke-width: 2px;
    }
    .node text {
      font-family: sans-serif;
      background: {{styles.colors.nodeFill}};
    }
    .node text.default {
      font-size: 16px;
    }
    .node text.http-method {
      font-size: 11px;
      font-weight: bold;
    }
    .link {
      fill: none;
      stroke: {{styles.colors.edgeStroke}};
      stroke-width: 2px;
    }
    .legendCells .label {
      font: 16px sans-serif;
      fill: {{styles.colors.foreground}};
    }
  </style>
</head>
<body>
  <script src="https://d3js.org/d3.v5.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3-legend/2.25.6/d3-legend.min.js"></script>
  <script>
    const routes = JSON.parse('{{data}}')
    const styles = JSON.parse('{{styles}}')

    const margin = {
      top: 20,
      right: 150,
      bottom: 20,
      left: 50
    }
    const width = document.body.clientWidth - margin.right - margin.left
    const height = window.innerHeight - margin.top - margin.bottom
    const duration = 250

    let i = 0
    let root

    const svg = d3.select("body")
      .append("svg")
      .attr("width", width + margin.right + margin.left - 10)
      .attr("height", height + margin.top + margin.bottom - 10)
      .append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")")

    const treemap = d3.tree()
      .size([ height, width ])

    root = d3.hierarchy(routes, d => d.children)
    root.x0 = height / 2
    root.y0 = 0

    const collapse = d => {
      if (d.children) {
        d._children = d.children;
        d._children.map(collapse);
        d.children = null;
      }
    }

    const update = source => {
      // Compute the new tree layout
      const treeData = treemap(root)
      const nodes = treeData.descendants()
      const links = treeData.descendants().slice(1)

      // Normalize for fixed-depth
      nodes.forEach(d => { d.y = d.depth * 275 })

      // Update the nodes
      const node = svg.selectAll("g.node")
        .data(nodes, d => d.id || (d.id = ++i))

      // Enter any new nodes at the parent's previous position
      const nodeEnter = node.enter().append("g")
        .attr("class", "node")
        .attr("transform", d => "translate(" + source.y0 + "," + source.x0 + ")")
        .on("click", click)

      nodeEnter.append("circle")
        .attr('class', 'node')
        .attr("r", 1e-5)

      nodeEnter.select('circle.node')
        .style("fill", d => d.children && d.children.length
          ? styles.colors.circle.fill
          : styles.colors.circle.leaf.fill
        )
        .style("stroke", d => d.children && d.children.length
          ? styles.colors.circle.stroke
          : styles.colors.circle.leaf.stroke
        )

      const isHttpMethod = str => str.match(/GET|POST|DELETE|PUT|PATCH/)

      // Add labels for the nodes
      nodeEnter.append('text')
        .attr('class', d => isHttpMethod(d.data.name) ? 'http-method' : 'default')
        .attr("dy", ".35em")
        .attr("x", d => 17)
        .attr("text-anchor", "start")
        .text(d => d.data.name)
        .style("fill-opacity", 1)
        .style('fill', d => d.data.name.match(/:/)
          ? styles.colors.parameterizedRoute
          : isHttpMethod(d.data.name)
          ? styles.colors.nodeFill
          : styles.colors.foreground
        )
        .call(selection => selection.each(function(d){ d.bbox = this.getBBox() }))

      nodeEnter.insert("rect", "text")
        .attr('class', d => isHttpMethod(d.data.name) ? 'http-method' : 'default')
        .attr("width", d => d.bbox.width + 12)
        .attr("height", d => d.bbox.height + (isHttpMethod(d.data.name) ? 8 : 15))
        .attr("y", d => -1.0 * d.bbox.height)
        .attr("x", d => 10)
        .attr("rx", d => isHttpMethod(d.data.name) ? 0 : 4)
        .attr("ry", d => isHttpMethod(d.data.name) ? 0 : 4)
        .style("fill", d => {
          return {
            GET: styles.colors.httpMethods.GET.fill,
            POST: styles.colors.httpMethods.POST.fill,
            DELETE: styles.colors.httpMethods.DELETE.fill,
            PUT: styles.colors.httpMethods.PUT.fill,
            PATCH: styles.colors.httpMethods.PATCH.fill
          }[d.data.name] || styles.colors.background
        })
        .style("stroke", d => isHttpMethod(d.data.name) ? styles.colors.nodeFill : styles.colors.nodeStroke)

      // Transition nodes to their new position.
      const nodeUpdate = nodeEnter.merge(node)

      nodeUpdate.transition()
        .duration(duration)
        .attr("transform", d => "translate(" + d.y + "," + d.x + ")");

      nodeUpdate.select("circle.node")
        .attr("r", 5)
        .attr('cursor', 'pointer')

      // Transition exiting nodes to the parent's new position.
      const nodeExit = node.exit().transition()
        .duration(duration)
        .attr("transform", d => "translate(" + source.y + "," + source.x + ")")
        .remove()

      nodeExit.select("circle")
        .attr("r", 1e-6)

      nodeExit.select("text")
        .style("fill-opacity", 1e-6)

      nodeExit.select('rect')
        .style("fill-opacity", 1e-6)
        .style('stroke-opacity', 1e-6)

      // Update the links…
      const link = svg.selectAll("path.link")
        .data(links, d => d.id)

      // Enter any new links at the parent's previous position.
      const linkEnter = link.enter().insert("path", "g")
        .attr("class", "link")
        .style("stroke-dasharray", ("3, 5"))
        .attr("d", d => {
          const o = { x: source.x0, y: source.y0 }
          return diagonal(o, o)
        })

      const linkUpdate = linkEnter.merge(link)

      // Transition links to their new position
      linkUpdate.transition()
        .duration(duration)
        .attr("d", d => diagonal(d, d.parent))

      // Transition exiting nodes to the parent's new position
      linkExit = link.exit().transition()
        .duration(duration)
        .attr('d', d => {
          const o = {x: source.x, y: source.y}
          return diagonal(o, o)
        })
        .remove()

      // Stash the old positions for transition
      nodes.forEach(d => {
        d.x0 = d.x
        d.y0 = d.y
      })
    }

    function diagonal(s, d) {
      const path = `M ${s.y} ${s.x}
        C ${(s.y + d.y) / 2} ${s.x},
          ${(s.y + d.y) / 2} ${d.x},
          ${d.y} ${d.x}`
      return path
    }

    update(root)

    const ordinal = d3.scaleOrdinal()
      .domain([ 'endpoint', 'parameterized'])
      .range([
        styles.colors.foreground,
        styles.colors.parameterizedRoute
      ]);

    svg.append("g")
      .attr("class", "legendOrdinal")
      .attr("transform", "translate(" + (width - margin.left) + "," + margin.top + ")");

    const legendOrdinal = d3.legendColor()
      .shape("path", d3.symbol().type(d3.symbolSquare).size(150)())
      .shapePadding(10)
      .scale(ordinal)

    svg.select(".legendOrdinal")
      .call(legendOrdinal)

    d3.select(self.frameElement).style("height", "1400px")

    // Toggle children on click.
    function click(d) {
      if (d.children) {
        d._children = d.children
        d.children = null
      } else {
        d.children = d._children
        d._children = null
      }
      update(d)
    }
  </script>
</body>