lib/spoom/coverage/d3/circle_map.rb



# typed: strict
# frozen_string_literal: true

require_relative "base"

module Spoom
  module Coverage
    module D3
      class CircleMap < Base
        class << self
          extend T::Sig

          sig { returns(String) }
          def header_style
            <<~CSS
              .node {
                cursor: pointer;
              }

              .node:hover {
                stroke: #333;
                stroke-width: 1px;
              }

              .label.dir {
                fill: #333;
              }

              .label.file {
                font: 12px Arial, sans-serif;
              }

              .node.root, .node.file {
                pointer-events: none;
              }
            CSS
          end

          sig { returns(String) }
          def header_script
            <<~JS
              function treeHeight(root, height = 0) {
                height += 1;
                if (root.children && root.children.length > 0)
                  return Math.max(...root.children.map(child => treeHeight(child, height)));
                else
                  return height;
              }

              function tooltipMap(d) {
                moveTooltip(d)
                  .html("<b>" + d.data.name + "</b>")
              }
            JS
          end
        end

        sig { override.returns(String) }
        def script
          <<~JS
            var root = {children: #{@data.to_json}}
            var dataHeight = treeHeight(root)

            var opacity = d3.scaleLinear()
                .domain([0, dataHeight])
                .range([0, 0.2])

            root = d3.hierarchy(root)
                .sum((d) => d.children ? d.children.length : 1)
                .sort((a, b) => b.value - a.value);

            var dirColor = d3.scaleLinear()
              .domain([1, 0])
              .range([strictnessColor("true"), strictnessColor("false")])
              .interpolate(d3.interpolateRgb);

            function redraw() {
              var diameter = document.getElementById("#{id}").clientWidth - 20;
              d3.select("##{id}").selectAll("*").remove()

              var svg_#{id} = d3.select("##{id}")
                .attr("width", diameter)
                .attr("height", diameter)
                .append("g")
                  .attr("transform", "translate(" + diameter / 2 + "," + diameter / 2 + ")");

              var pack = d3.pack()
                  .size([diameter, diameter])
                  .padding(2);

              var focus = root,
                  nodes = pack(root).descendants(),
                  view;

              var circle = svg_#{id}.selectAll("circle")
                .data(nodes)
                .enter().append("circle")
                  .attr("class", (d) => d.parent ? d.children ? "node" : "node file" : "node root")
                  .attr("fill", (d) => d.children ? dirColor(d.data.score) : strictnessColor(d.data.strictness))
                  .attr("fill-opacity", (d) => d.children ? opacity(d.depth) : 1)
                  .on("click", function(d) { if (focus !== d) zoom(d), d3.event.stopPropagation(); })
                  .on("mouseover", (d) => tooltip.style("opacity", 1))
                  .on("mousemove", tooltipMap)
                  .on("mouseleave", (d) => tooltip.style("opacity", 0));

              var text = svg_#{id}.selectAll("text")
                .data(nodes)
                .enter().append("text")
                  .attr("class", (d) => d.children ? "label dir" : "label file")
                  .attr("fill-opacity", (d) => d.depth <= 1 ? 1 : 0)
                  .attr("display", (d) => d.depth <= 1 ? "inline" : "none")
                  .text((d) => d.data.name);

              var node = svg_#{id}.selectAll("circle,text");

              function zoom(d) {
                var focus0 = focus; focus = d;

                var transition = d3.transition()
                    .duration(d3.event.altKey ? 7500 : 750)
                    .tween("zoom", function(d) {
                      var i = d3.interpolateZoom(view, [focus.x, focus.y, focus.r * 2]);
                      return (t) => zoomTo(i(t));
                    });

                transition.selectAll("text")
                  .filter(function(d) { return d && d.parent === focus || this.style.display === "inline"; })
                    .attr("fill-opacity", function(d) { return d.parent === focus ? 1 : 0; })
                    .on("start", function(d) { if (d.parent === focus) this.style.display = "inline"; })
                    .on("end", function(d) { if (d.parent !== focus) this.style.display = "none"; });
              }

              function zoomTo(v) {
                var k = diameter / v[2]; view = v;
                node.attr("transform", (d) => "translate(" + (d.x - v[0]) * k + "," + (d.y - v[1]) * k + ")");
                circle.attr("r", (d) => d.r * k);
              }

              zoomTo([root.x, root.y, root.r * 2]);
              d3.select("##{id}").on("click", () => zoom(root));
            }

            redraw();
            window.addEventListener("resize", redraw);
          JS
        end

        class Sigils < CircleMap
          extend T::Sig

          sig do
            params(
              id: String,
              file_tree: FileTree,
              nodes_strictnesses: T::Hash[FileTree::Node, T.nilable(String)],
              nodes_scores: T::Hash[FileTree::Node, Float],
            ).void
          end
          def initialize(id, file_tree, nodes_strictnesses, nodes_scores)
            @nodes_strictnesses = nodes_strictnesses
            @nodes_scores = nodes_scores
            super(id, file_tree.roots.map { |r| tree_node_to_json(r) })
          end

          sig { params(node: FileTree::Node).returns(T::Hash[Symbol, T.untyped]) }
          def tree_node_to_json(node)
            if node.children.empty?
              {
                name: node.name,
                strictness: @nodes_strictnesses.fetch(node, "false"),
              }
            else
              {
                name: node.name,
                children: node.children.values.map { |n| tree_node_to_json(n) },
                score: @nodes_scores.fetch(node, 0.0),
              }
            end
          end
        end
      end
    end
  end
end