4

I create directed graphs like the following from wikidata with the help of networkx and nxv. The result is an svg file which might be embedded in some html page.

wikidata digraph

Now I want that every node and every edge is "clickable", such that a user can add their comments to specific elements of the graph. I think this could be done with a modal dialog popping up. This dialog should know from which element it was triggered and it should send the content of the textarea to some url via a post request.

What would be the best way to achieve this?

cknoll
  • 2,130
  • 4
  • 18
  • 34
  • 1
    I read that OP wants to add comments, not change the SVG. So add an ``id`` and a ``onclick`` handler to every SVG element – Danny '365CSI' Engelman Feb 21 '21 at 21:28
  • I am thinking that [GraphViz](https://graphviz.org/) should be able to do this. – Guy Coder Feb 22 '21 at 11:00
  • 2
    Related question: [Are there event listeners to detect mouse clicks on dot/SVG graph?](https://stackoverflow.com/q/12257273/1243762) – Guy Coder Feb 22 '21 at 11:01
  • 1
    Personally I was a big fan of GraphViz and still am a fan but my new favorite graph library for use with browsers is [Cytoscape](https://cytoscape.org/). The reason this is not an answer is because you asked for SVG and this is not SVG. Cytoscape can export an SVG but I don't know if it will allow for mouse events. – Guy Coder Feb 22 '21 at 11:03

2 Answers2

3

Wrapped in a W3C standard Web Component (JSWC supported in all Modern Browsers) you can make it generic for any src="filename.svg"

<graphviz-svg-annotator src="fsm.svg"></graphviz-svg-annotator>
<graphviz-svg-annotator src="Linux_kernel_diagram.svg"></graphviz-svg-annotator>
<style>
  svg .annotate { cursor:pointer }
</style>
<script>
  customElements.define('graphviz-svg-annotator', class extends HTMLElement {
    constructor() {
      let loadSVG = async ( src , container = this.shadowRoot ) => {
        container.innerHTML = `<style>:host { display:inline-block }
                               ::slotted(svg)  { width:100%;height:200px }
                               </style>
                               <slot name="svgonly">Loading ${src}</slot>`;
        this.innerHTML = await(await fetch(src)).text(); // load full XML in lightDOM
        let svg = this.querySelector("svg");
        svg.slot = "svgonly"; // show only SVG part in shadowDOM slot
        svg.querySelectorAll('g[id*="node"],g[id*="edge"]').forEach(g => {
          let label  = g.querySelector("text")?.innerHTML || "No label";
          let shapes = g.querySelectorAll("*:not(title):not(text)");
          let fill   = (color = "none") => shapes.forEach(x => x.style.fill = color);
          let prompt = "Please annotate: ID: " + g.id + " label: " + label; 
          g.classList.add("annotate");
          g.onmouseenter = evt => fill("lightgreen");
          g.onmouseleave = evt => fill();
          g.onclick = evt => g.setAttribute("annotation", window.prompt(prompt));
        })
      }
      super().attachShadow({ mode: 'open' });
      loadSVG("//graphviz.org/Gallery/directed/"+this.getAttribute("src"));
    }});
</script>

Detailed:

  • this.innerHTML = ... injects the full XML in the component ligthDOM
    (because the element has shadowDOM, the lightDOM is not visible in the Browser)

  • But you only want the SVG part (graphviz XML has too much data)... and you don't want a screen flash; that is why I put the XML .. invisible.. in lightDOM

  • A shadowDOM <slot> is used to only reflect the <svg>

  • with this method the <svg> can still be styled from global CSS (see cursor:pointer)

  • With multiple SVGs on screen <g> ID values could conflict.
    The complete SVG can be moved to shadowDOM with:

     let svg = container.appendChild( this.querySelector("svg") );
    

    But then you can't style the SVG with global CSS any more, because global CSS can't style shadowDOM

Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
2

As far as I know, nxv generates a g element with class "node" for each node, all nested inside a graph g. So basically you could loop over all gs elements inside the main group and attach a click event listener on each one. (actually, depending of the desired behavior, you might want to attach the event listener to the shape inside the g, as done below. For the inside of the shape to be clickable, it has to be filled)

On click, it would update a form, to do several things: update its style to show it as a modal (when submitted, the form should go back to hiding), and update an hidden input with the text content of the clicked g.

Basically it would be something like that:

<svg>Your nxv output goes here</svg>

<form style="display: none;">
  <input type="hidden" id="node_title">
  <textarea></textarea>
  <input type="submit" value="Send!">
</form>

<script>
const graph = document.querySelector("svg g");
const form = document.querySelector("form");
[...graph.querySelectorAll("g")].map(g => { //loop over each g element inside graph
  if (g.getAttribute("class") == "node") { //filter for nodes
    let target = "polygon";
    if (g.querySelector("polygon") === null) {
      target = "ellipse";
    }
    g.querySelector(target).addEventListener("click",() => {
      const node_title = g.querySelector("text").innerHTML;
      form.querySelector("#node_title").setAttribute("value", node_title);
      form.setAttribute("style","display: block;");
    });
  }
});

const submitForm = async (e) => { //function for handling form submission
  const endpoint = "path to your POST endpoint";
  const body = {
    source_node: form.querySelector("#node_title").value,
    textarea: form.querySelector("textarea").value
  }
  e.preventDefault(); //prevent the default form submission behavior
  let response = await fetch(endpoint, { method: "POST", body: JSON.stringify(body) });
  // you might wanna do something with the server response
  // if everything went ok, let's hide this form again & reset it
  form.querySelector("#node_title").value = "";
  form.querySelector("textarea").value = "";
  form.setAttribute("style","display: none;");
}
form.addEventListener("submit",submitForm);
</script>
corbin-c
  • 669
  • 8
  • 20