javascriptplottooltipobservablehq

Adding Tooltip Functionality to ObservableHQ Plot Locally


Would there be an easy way to implement this addTooltip function from Mike Freeman's Plot Tooltip Notebook within a local vanilla JavaScript environment? In addition, what would be the best way to manage user input and interactivity with Plot locally? I realize Observable makes all of this a lot less painful to code. Was just hoping there would be solutions outside of the website. Or should I just go the D3.js route if I want to do these things?

<html>

<head>
    <meta name=”robots” content=”noindex”>
    <meta charset="UTF-8">
    <title>Example Plots</title>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.4"></script>
</head>

<body>
    <div >
        <h1>Iris Dataset</h1>
        <div id="chart1"></div>
    </div>
</body>


<script type="module">

const iris = await d3.json("https://cdn.jsdelivr.net/npm/vega-datasets@1.31.1/data/iris.json");

const scatter = function(data) {
  const div = document.getElementById("chart1")
  div.appendChild(Plot.plot({
    marks: [
      Plot.dot(data, {x: "sepalLength", y: "sepalWidth", stroke: "species"}),
    ],              
  }));
}

scatter(iris)



 
</script>

</html>


Solution

  • As you can see in my example below, you just need to import the htl.html requirement and import (copy and paste) the addTooltips, hover and id_generator cells as functions.

    cells dependency

    <html>
    
    <head>
        <meta name=”robots” content=”noindex”>
        <meta charset="UTF-8">
        <title>Example Plots</title>
        <script src="https://d3js.org/d3.v7.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.4"></script>
         <script src="https://cdn.jsdelivr.net/npm/htl@0.3.1/dist/htl.min.js"></script>
    
    </head>
    
    <body>
        <div >
            <h1>Iris Dataset</h1>
            <div id="chart1"></div>
        </div>
    </body>
    
    
    <script type="module">
    const iris = await d3.json("https://cdn.jsdelivr.net/npm/vega-datasets@1.31.1/data/iris.json");
    const html = htl.html
    
    function scatter(data) {
      const div = document.getElementById("chart1")
      div.appendChild(addTooltips(Plot.plot({
        marks: [
          Plot.dot(data, {x: "sepalLength", y: "sepalWidth", stroke: "species", 
              title: (d) =>
          `${d.species} \n Sepal Length: ${d.sepalLength} \n Sepal Width: ${d.sepalWidth}` 
          }),
        ],              
      })))
    }
    
    function addTooltips(chart, hover_styles = { fill: "blue", opacity: 0.5 }){
      let styles = hover_styles;
      const line_styles = {
        stroke: "blue",
        "stroke-width": 3
      };
      // Workaround if it's in a figure
      const type = d3.select(chart).node().tagName;
      let wrapper =
        type === "FIGURE" ? d3.select(chart).select("svg") : d3.select(chart);
    
      // Workaround if there's a legend....
      const numSvgs = d3.select(chart).selectAll("svg").size();
      if (numSvgs === 2)
        wrapper = d3
          .select(chart)
          .selectAll("svg")
          .filter((d, i) => i === 1);
      wrapper.style("overflow", "visible"); // to avoid clipping at the edges
    
      // Set pointer events to visibleStroke if the fill is none (e.g., if its a line)
      wrapper.selectAll("path").each(function (data, index, nodes) {
        // For line charts, set the pointer events to be visible stroke
        if (
          d3.select(this).attr("fill") === null ||
          d3.select(this).attr("fill") === "none"
        ) {
          d3.select(this).style("pointer-events", "visibleStroke");
          styles = _.isEqual(hover_styles, { fill: "blue", opacity: 0.5 })
            ? line_styles
            : hover_styles;
        }
      });
    
      const tip = wrapper
        .selectAll(".hover-tip")
        .data([""])
        .join("g")
        .attr("class", "hover")
        .style("pointer-events", "none")
        .style("text-anchor", "middle");
    
      // Add a unique id to the chart for styling
      const id = id_generator();
    
      // Add the event listeners
      d3.select(chart)
        .classed(id, true) // using a class selector so that it doesn't overwrite the ID
        .selectAll("title")
        .each(function () {
          // Get the text out of the title, set it as an attribute on the parent, and remove it
          const title = d3.select(this); // title element that we want to remove
          const parent = d3.select(this.parentNode); // visual mark on the screen
          const t = title.text();
          if (t) {
            parent.attr("__title", t).classed("has-title", true);
            title.remove();
          }
          // Mouse events
          parent
            .on("mousemove", function (event) {
              const text = d3.select(this).attr("__title");
              const pointer = d3.pointer(event, wrapper.node());
              if (text) tip.call(hover, pointer, text.split("\n"));
              else tip.selectAll("*").remove();
    
              // Raise it
              d3.select(this).raise();
              // Keep within the parent horizontally
              const tipSize = tip.node().getBBox();
              if (pointer[0] + tipSize.x < 0)
                tip.attr(
                  "transform",
                  `translate(${tipSize.width / 2}, ${pointer[1] + 7})`
                );
              else if (pointer[0] + tipSize.width / 2 > wrapper.attr("width"))
                tip.attr(
                  "transform",
                  `translate(${wrapper.attr("width") - tipSize.width / 2}, ${
                    pointer[1] + 7
                  })`
                );
            })
            .on("mouseout", function (event) {
              tip.selectAll("*").remove();
              // Lower it!
              d3.select(this).lower();
            });
        });
    
      // Remove the tip if you tap on the wrapper (for mobile)
      wrapper.on("touchstart", () => tip.selectAll("*").remove());
      // Add styles
      const style_string = Object.keys(styles)
        .map((d) => {
          return `${d}:${styles[d]};`;
        })
        .join("");
    
      // Define the styles
      const style = html`<style>
          .${id} .has-title {
           cursor: pointer; 
           pointer-events: all;
          }
          .${id} .has-title:hover {
            ${style_string}
        }
        </style>`;
      chart.appendChild(style);
      return chart;
    }
    
    function hover(tip, pos, text){
      const side_padding = 10;
      const vertical_padding = 5;
      const vertical_offset = 15;
    
      // Empty it out
      tip.selectAll("*").remove();
    
      // Append the text
      tip
        .style("text-anchor", "middle")
        .style("pointer-events", "none")
        .attr("transform", `translate(${pos[0]}, ${pos[1] + 7})`)
        .selectAll("text")
        .data(text)
        .join("text")
        .style("dominant-baseline", "ideographic")
        .text((d) => d)
        .attr("y", (d, i) => (i - (text.length - 1)) * 15 - vertical_offset)
        .style("font-weight", (d, i) => (i === 0 ? "bold" : "normal"));
    
      const bbox = tip.node().getBBox();
    
      // Add a rectangle (as background)
      tip
        .append("rect")
        .attr("y", bbox.y - vertical_padding)
        .attr("x", bbox.x - side_padding)
        .attr("width", bbox.width + side_padding * 2)
        .attr("height", bbox.height + vertical_padding * 2)
        .style("fill", "white")
        .style("stroke", "#d3d3d3")
        .lower();
    }
    function id_generator(){
      var S4 = function () {
        return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
      };
      return "a" + S4() + S4();
    }
    scatter(iris)
    
    
    
     
    </script>
    
    </html>