Approach for Interactive Venn Diagram

Hi,

I have a question about implementing an interactive venn diagram in GoJS. Before going too far down one approach, I wanted to ask here for some guidance. The screenshot of the UX is below.

The user will be able to toggle on and off each of the 3 portions of the diagram. The combination of the toggle states determines the join type. My gut instinct is that this is a combination of 3 separate svgs shapes, each with their own ability to act on. But not sure about aligning the 3 to form a single shape. I feel like I could do it with a bunch of tweaking of static values but not sure if there is a more automatic way.

Any examples or tips would be much appreciated. Thanks

Here’s a VennLayout that supports two or three "Set"s. You can customize how each intersection collection is laid out by setting VennLayout.intersectionLayout – the default is CircularLayout, but you could use PackedLayout as I did originally in this post.

<!DOCTYPE html>
<html>

<head>
  <title>Venn Diagram of Common Glyphs in Greek, Latin, and Russian</title>
  <!-- Copyright 1998-2025 by Northwoods Software Corporation. -->
  <meta name="description" content="">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>
  <a href="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Venn_diagram_gr_la_ru.svg/830px-Venn_diagram_gr_la_ru.svg.png">
    Venn Diagram of Greek, Latin, and Russian Glyphs</a>
  <br>
  <button onclick="lgr()">Greek, Latin, and Russian</button>
  <button onclick="lg()">Latin and Greek</button>
  <button onclick="gr()">Greek and Russian</button>
  <button onclick="lr()">Latin and Russian</button>
  <br>
  <button onclick="circular()">Circular Layout</button>
  <button onclick="packed()">Packed Layout</button>

  <script src="https://cdn.jsdelivr.net/npm/gojs"></script>
  <script src="https://cdn.jsdelivr.net/npm/create-gojs-kit/dist/extensions/Quadtree.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/create-gojs-kit/dist/extensions/PackedLayout.js"></script>
  <script id="code">

// This Venn-style layout handles two or three sets of nodes, where each node.data object
// has a "sets" property that is an Array listing which "Set"s the node belongs in.
// The model should also hold a category: "Set" node data object for each collection
// that is represented by a circular Shape.
// To arrange the collection of nodes in each "Set" intersection, you can either use
// the default CircularLayout or you can use a different one such as PackedLayout.
class VennLayout extends go.Layout {
  _sets = null;
  _bins = null;
  _intersectionLayout = new go.CircularLayout({ arrangement: go.CircularArrangement.Packed, spacing: 0 });
  _spacing = 0;

  constructor(init) {
    super();
    if (init) Object.assign(this, init);
  }

  // Gets or sets the Layout to use for each collection of nodes.
  // This defaults to CircularLayout.
  get intersectionLayout() { return this._intersectionLayout; }
  set intersectionLayout(lay) {
    if (lay !== this._intersectionLayout) {
      this._intersectionLayout = lay;
      this.invalidateLayout();
    }
  }

  // Gets or sets the minimum distance between intersection collections.
  // This may want to be larger than zero when the intersections are laid out in a rectangular fashion,
  // since the circles of the "Set"s will tend to intersect with the corners of rectangles.
  // This defaults to 0.
  get spacing() { return this._spacing; }
  set spacing(sp) {
    if (sp !== this._spacing) {
      this._spacing = sp;
      this.invalidateLayout();
    }
  }

  doLayout(coll) {
    if (coll === this.diagram) {
      coll = this.diagram.nodes.filter(n => n.isVisible());
    } else if (coll instanceof go.Group) {
      coll = coll.memberParts.filter(p => p.isVisible() && !(p instanceof go.Link));
    } else {
      coll = coll.filter(p => p.isVisible() && !(p instanceof go.Link));
    }
    this._sets = new go.Map();
    // first generate an array of the keys that name the "Sets"
    const setarr = [];
    coll.each(n => {
      if (n.category === "Set") {
        const key = n.data.key;
        this._sets.add(key, { members: [], key: key, node: n });
        setarr.push(key);
      }
    });
    // build tree structure with all combinations
    this._bins = this._buildBin(setarr, 0, []);
    // for each node that is not a "Set", add to bin corresponding to node.data.sets array of keys
    coll.each(n => {
      if (n.category === "Set") return;
      n.data.sets.forEach(k => {
        const setinfo = this._sets.get(k);
        if (setinfo) {
          setinfo.members.push(n);
        } else {
          // be tolerant of superfluous Set membership information -- ignore it
          // throw new Error("unknown set: " + k + " for node " + n.data.key);
        }
      });
      this._walkBin(this._bins,
        intinfo => n.data.sets.indexOf(intinfo.key) >= 0,
        intinfo => n.data.sets.indexOf(intinfo.key) < 0,
        intinfo => intinfo.members.push(n));
    });
    // compute how big each laid-out collection of nodes in each intersection is
    let maxdia = 0.0;
    this._walkBin(this._bins, null, null, intinfo => {
      this._layoutIntersection(intinfo);
      const bounds = this.diagram.computePartsBounds(intinfo.members);
      intinfo.center = bounds.center;
      maxdia = Math.max(maxdia, Math.max(bounds.width + this.spacing/2, bounds.height + this.spacing/2));
    });
    // position the "sets"
    // their diameters should depend on the maximum size of the intersection collections after their layout
    let i = 1;
    const dia = maxdia * this._sets.count;
    this._sets.iteratorValues.each(setinfo => {
      const shp = setinfo.node.findObject("SHAPE");
      const lab = setinfo.node.findObject("LABEL");
      if (i === 1) {
        if (shp) shp.desiredSize = new go.Size(dia, dia);
        if (lab) { lab.alignment = new go.Spot(0.15, 0.15); lab.alignmentFocus = go.Spot.BottomRight; }
        setinfo.node.location = new go.Point(0, 0); i++;
      } else if (i === 2) {
        if (shp) shp.desiredSize = new go.Size(dia, dia);
        if (lab) { lab.alignment = new go.Spot(0.85, 0.15); lab.alignmentFocus = go.Spot.BottomLeft; }
        setinfo.node.location = new go.Point(dia / 2, 0); i++;
      } else if (i === 3) {
        if (shp) shp.desiredSize = new go.Size(dia, dia);
        if (lab) { lab.alignment = go.Spot.Bottom; lab.alignmentFocus = go.Spot.Top; }
        setinfo.node.location = new go.Point(dia / 4, 0.86 * dia / 2); i++;
      } else if (i > 3) {
        throw new Error("too many sets: " + i);
      }
    });
    // position the intersection collections
    const center = this._average(setarr);
    this._walkBin(this._bins, null, null, intinfo => {
      const avg = this._average(intinfo.path);
      const c = intinfo.center;
      intinfo.members.forEach(n => {
        const dst = new go.Point(center.x + 2 * (avg.x - center.x), center.y + 2 * (avg.y - center.y));
        n.moveTo(dst.x + n.location.x - c.x, dst.y + n.location.y - c.y, true);
      });
    });
  };

  _buildBin(arr, idx, path) {
    if (idx < arr.length) {
      const k = arr[idx];
      const t = { key: k };
      t[false] = this._buildBin(arr, idx + 1, path);
      path.push(k);
      t[true] = this._buildBin(arr, idx + 1, path);
      path.pop();
      return t;
    } else {
      return {
        members: [],
        path: path.slice(),
        size: new go.Size()
      };
    }
  };

  _walkBin(bins, present, absent, leaf) {
    if (Array.isArray(bins.members)) {
      if (leaf) leaf(bins);
    } else {
      const k = bins.key;
      if (!present || present(bins)) {
        this._walkBin(bins[true], present, absent, leaf);
      }
      if (!absent || absent(bins)) {
        this._walkBin(bins[false], present, absent, leaf);
      }
    }
  };

  _layoutIntersection(intinfo) {
    const arr = intinfo.members;
    const coll = new go.List().addAll(arr);
    this._intersectionLayout.doLayout(coll);
  };

  _average(keys) {
    const len = keys.length;
    let x = 0.0;
    let y = 0.0;
    for (let i = 0; i < len; i++) {
      const key = keys[i];
      const node = this._sets.get(key).node;
      if (i === 0) {
        x = node.location.x;
        y = node.location.y;
      } else {
        x += node.location.x;
        y += node.location.y;
      }
    }
    if (len > 0) {
      x = x / len;
      y = y / len;
    }
    return new go.Point(x, y);
  }
}
// end VennLayout

myDiagram =
  new go.Diagram("myDiagramDiv", {
      "animationManager.isEnabled": false,
      layout: new VennLayout()
    });

myDiagram.nodeTemplateMap.add("Set",
  new go.Node("Spot", {
      locationSpot: go.Spot.Center, locationObjectName: "SHAPE",
      layerName: "Background",
      selectable: false
    })
    .add(
      new go.Shape("Circle", { name: "SHAPE", fill: "transparent" }),
      new go.TextBlock({ name: "LABEL", font: "bold 12pt sans-serif" })
        .bind("text")
    ));

myDiagram.nodeTemplate =
  new go.Node({ locationSpot: go.Spot.Center })
    .add(
      new go.TextBlock()
        .bind("text")
    );

myDiagram.model = new go.GraphLinksModel([
  { key: "G", text: "Greek", category: "Set" },
  { key: "L", text: "Latin", category: "Set" },
  { key: "R", text: "Russian", category: "Set" },
  { text: "A", sets: ["G", "L", "R"] },
  { text: "B", sets: ["G", "L", "R"] },
  { text: "C", sets: ["L", "R"] },
  { text: "D", sets: ["L"] },
  { text: "E", sets: ["G", "L", "R"] },
  { text: "F", sets: ["L"] },
  { text: "G", sets: ["L"] },
  { text: "H", sets: ["G", "L", "R"] },
  { text: "I", sets: ["G", "L"] },
  { text: "J", sets: ["L"] },
  { text: "K", sets: ["G", "L", "R"] },
  { text: "L", sets: ["L"] },
  { text: "M", sets: ["G", "L", "R"] },
  { text: "N", sets: ["G", "L"] },
  { text: "O", sets: ["G", "L", "R"] },
  { text: "P", sets: ["G", "L", "R"] },
  { text: "Q", sets: ["L"] },
  { text: "R", sets: ["L"] },
  { text: "S", sets: ["L"] },
  { text: "T", sets: ["G", "L", "R"] },
  { text: "U", sets: ["L"] },
  { text: "V", sets: ["L"] },
  { text: "W", sets: ["L"] },
  { text: "X", sets: ["G", "L", "R"] },
  { text: "Y", sets: ["G", "L", "R"] },
  { text: "Z", sets: ["G", "L"] },
  { text: "Γ", sets: ["G", "R"] },
  { text: "Δ", sets: ["G"] },
  { text: "Θ", sets: ["G"] },
  { text: "Λ", sets: ["G"] },
  { text: "Ξ", sets: ["G"] },
  { text: "Π", sets: ["G", "R"] },
  { text: "Σ", sets: ["G"] },
  { text: "Φ", sets: ["G", "R"] },
  { text: "Ψ", sets: ["G"] },
  { text: "Ω", sets: ["G"] },
  { text: "Б", sets: ["R"] },
  { text: "Д", sets: ["R"] },
  { text: "Ж", sets: ["R"] },
  { text: "З", sets: ["R"] },
  { text: "И", sets: ["R"] },
  { text: "Л", sets: ["R"] },
  { text: "Ц", sets: ["R"] },
  { text: "Ч", sets: ["R"] },
  { text: "Ш", sets: ["R"] },
  { text: "Щ", sets: ["R"] },
  { text: "Ъ", sets: ["R"] },
  { text: "Ы", sets: ["R"] },
  { text: "Ь", sets: ["R"] },
  { text: "Э", sets: ["R"] },
  { text: "Ю", sets: ["R"] },
  { text: "Я", sets: ["R"] }
]);

function lgr() {  // make all Nodes visible
  myDiagram.commit(d => {
    d.nodes.each(n => n.visible = true);
  });
}

function setVisibleIf(a, b) {
  myDiagram.commit(d => {
    d.nodes.each(n => {
      if (n.category === "Set") {
        n.visible = n.key === a || n.key === b;
      } else {
        n.visible = n.data.sets.indexOf(a) >= 0 || n.data.sets.indexOf(b) >= 0;
      }
    });
  });
}

function lg() { setVisibleIf("L", "G"); }
function gr() { setVisibleIf("G", "R"); }
function lr() { setVisibleIf("L", "R"); }

function circular() {
  myDiagram.commit(d => {
    d.layout.intersectionLayout = new go.CircularLayout({ arrangement: go.CircularArrangement.Packed, spacing: 0 })
    d.layout.spacing = 0;
  });
}

function packed() {
  myDiagram.commit(d => {
    d.layout.intersectionLayout = new PackedLayout({ spacing: 0 });
    d.layout.spacing = 20;
  });
}
  </script>
</body>

</html>

Thank you very much @walter!

Will use your snippet as a starting point

I have cleaned up the VennLayout code, had it position the “Set” labels, and generalized the sample to allow showing just the intersections of two of the "Set"s and to use either CircularLayout or PackedLayout.

It’s now all in my original post, above.