Search

Combo Options

Combos

Combine similar nodes to simplify networks.

Combo Options
View live example →

Complex, data-heavy visualisations can make it difficult to see underlying patterns.

Combos in KeyLines put multiple nodes into a combined node without affecting the links between them, which can help you simplify charts and spot partnerships such as members of the same gang or a terrorist group.

Open and closed state

In closed combos, items are hidden and their number is summarized by a default counter glyph.

Open combos show the items within a combo border. You can enable the counter glyph on open combos by setting comboGlyphOnOpen to true.

Nested combos

Nested combos are combos created inside other combos. There’s no limit to the number of nested levels, and you can uncombine them in sequence.

Combo options

The shape property controls the shape of all open combos in the chart. It can be set to 'circle' (default) or 'rectangle'.

There are several ways how nodes inside open combos can be arranged. Choosing the 'grid' option, which is default for rectangular combos, also lets you control the row or column dimension of open combos with the gridShape option. Any arrangement can be made tighter or looser using the tightness option.

See also

Documentation: Combos and Combos Concepts

Demos: Combining Nodes and Combo Dragging

Key functions used:

import KeyLines from "keylines";
import { data } from "./data.js";

let chart;
const purple = "#9767ba";
const validArrangeOptions = {
  sequential: ["tightness", "stretch", "orientation"],
  lens: ["tightness"],
  concentric: ["tightness"],
  grid: ["tightness", "gridShape"],
};

function enableButton(el, enabled) {
  const button = typeof el === "string" ? document.getElementById(el) : el;
  if (enabled) {
    button.classList.remove("disabled");
    button.removeAttribute("disabled");
  } else {
    button.classList.add("disabled");
    button.setAttribute("disabled", "");
  }
}

function isComboNode(id) {
  return chart.combo().isCombo(id, { type: "node" });
}

function isTopLevel(id) {
  return chart.combo().find(id) === null;
}

function isTopLevelComboNodeSelected() {
  return chart.selection().some((id) => isComboNode(id) && isTopLevel(id));
}

function selectedNodes() {
  return chart.selection().filter((id) => chart.getItem(id).type === "node");
}

function selectedTopLevelNodes() {
  return selectedNodes().filter(isTopLevel);
}

function multipleNodesSelected() {
  return selectedTopLevelNodes().length > 1;
}

function getArrange() {
  const arrange = {
    tightness: getSliderValue("tightness"),
    name: getRadioButtonValue("arrange"),
  };

  if (arrange.name === "grid") {
    const gridShape = getRadioButtonValue("gridShape");
    if (gridShape === "row") {
      arrange.gridShape = { rows: 1 };
    } else if (gridShape === "col") {
      arrange.gridShape = { columns: 1 };
    }
  }

  if (arrange.name === "sequential") {
    arrange.orientation = getRadioButtonValue("orientation");
    arrange.stretch = getSliderValue("stretch");
  }

  return arrange;
}

// generate a list of all combos ordered by how deeply nested they are
function getComboList() {
  const comboIds = [];
  chart.each({ type: "node", items: "all" }, ({ id }) => {
    if (chart.combo().isCombo(id)) {
      comboIds.push(id);
    }
  });
  return comboIds;
}

function onSelection() {
  enableButton("combine", multipleNodesSelected());
  enableButton("uncombine", isTopLevelComboNodeSelected());
}

async function combineSelected() {
  enableButton("combine", false);
  await chart.combo().combine(
    {
      ids: selectedTopLevelNodes(),
      label: "Group",
      open: true,
      style: {
        fi: {
          t: "fas fa-users",
          c: "white",
        },
        c: purple,
        e: 1.2,
      },
    },
    { arrange: getArrange() }
  );
}

async function uncombineSelected() {
  await chart.combo().uncombine(chart.selection(), {
    full: document.getElementById("uncombineAll").checked,
  });
}

function doLayout(opts = {}) {
  return chart.layout("organic", opts);
}

function arrangeAllCombos(opts = {}) {
  updateArrangeOptionsPanel();
  return chart.combo().arrange(getComboList(), Object.assign(getArrange(), opts));
}

function updateArrangeOptionsPanel() {
  const validOptions = validArrangeOptions[getArrange().name] ?? [];

  // gather up all the unique arrange options
  const arrangeOptions = new Set();
  document.querySelectorAll("#arrange-options .opt-container").forEach((v) => {
    arrangeOptions.add(v.getAttribute("name"));
  });

  // and filter them out based on the new arrange selected
  for (const opt of arrangeOptions) {
    setInputHidden(opt, !validOptions.includes(opt));
  }
}

async function setComboShape() {
  chart.options({ combos: { shape: getRadioButtonValue("shape") } });
  // Arrange all combos from innermost to outmost to size the new shape appropriately and
  // update the arrangement to take account of the new size of any nested combos
  await arrangeAllCombos({ animate: false });
  return doLayout({ mode: "adaptive", fit: false });
}

function getSliderValue(name) {
  const container = document.querySelector(`.opt-container[name="${name}"]`);
  return parseFloat(container.querySelector(`input[type="range"]`).value);
}

function getRadioButtonValue(name) {
  const container = document.querySelector(`.opt-container[name="${name}"]`);
  return container.querySelector(`button.active`).value;
}

function setupSlider(name, fn) {
  const container = document.querySelector(`.opt-container[name="${name}"]`);
  const input = container.querySelector('input[type="range"]');
  const label = container.querySelector(".value");

  input.addEventListener("input", () => {
    label.innerText = getSliderValue(name);
  });
  input.addEventListener("change", fn);
}

function setInputHidden(name, hidden) {
  const container = document.querySelector(`.opt-container[name="${name}"]`);
  container.style.display = hidden ? "none" : "block";
}

function setupRadioButton(name, fn) {
  const container = document.querySelector(`.opt-container[name="${name}"]`);
  const allInputs = container.querySelectorAll("button");

  allInputs.forEach((target) => {
    target.addEventListener("click", (e) => {
      allInputs.forEach((other) => {
        if (other !== target) {
          other.classList.remove("active");
        }
      });
      target.classList.add("active");
      fn(e);
    });
  });
}

function setUpEventHandlers() {
  // set up the button enabled states
  chart.on("selection-change", onSelection);

  // radio buttons
  setupRadioButton("shape", setComboShape);
  setupRadioButton("arrange", () => arrangeAllCombos());
  setupRadioButton("orientation", () => arrangeAllCombos());
  setupRadioButton("gridShape", () => arrangeAllCombos());

  // sliders
  setupSlider("tightness", () => arrangeAllCombos());
  setupSlider("stretch", () => arrangeAllCombos());

  // buttons
  document.getElementById("combine").addEventListener("click", combineSelected);
  document.getElementById("uncombine").addEventListener("click", uncombineSelected);
  document.getElementById("layout").addEventListener("click", () => doLayout());

  // set up the initial look
  updateArrangeOptionsPanel();
  onSelection();
}

async function startKeyLines() {
  // Font Icons and images can often be poorly aligned,
  // set offsets to the icons to ensure they are centred correctly
  const imageAlignment = {};
  const imageAlignmentDefinitions = {
    "fas fa-user": { e: 0.9 },
    "fas fa-users": { e: 0.85 },
  };

  // List of icons to realign
  const icons = Object.keys(imageAlignmentDefinitions);
  icons.forEach((icon) => {
    imageAlignment[icon] = imageAlignmentDefinitions[icon];
  });

  chart = await KeyLines.create({
    container: "klchart",
    options: {
      drag: {
        links: false,
      },
      selectionColour: "#444",
      imageAlignment,
      logo: { u: "/images/Logo.png" },
      // Set the name of the font we want to use for icons (a font must be loaded in the browser
      // with exactly this name)
      iconFontFamily: "Font Awesome 5 Free",
      defaultStyles: {
        comboGlyph: {
          c: "#444",
          b: "rgba(0,0,0,0)",
          fc: "white",
        },
        comboLinks: {
          c: purple,
          w: 5,
        },
        openCombos: {
          c: "rgba(246,238,248,0.8)",
          bw: 2,
          b: purple,
        },
      },
    },
  });
  chart.load(data());
  // create one combo right at the start
  await chart.layout(undefined, { animate: false });
  await chart.combo().combine(
    {
      ids: ["c", "b", "a", "j"],
      label: "Group",
      open: true,
      style: {
        fi: {
          t: "fas fa-users",
          c: "white",
        },
        c: purple,
        e: 1.2,
      },
    },
    { arrange: getArrange(), animate: false }
  );
  chart.layout();
  setUpEventHandlers();
}

function loadKeyLines() {
  document.fonts.load('24px "Font Awesome 5 Free"').then(startKeyLines);
}

window.addEventListener("DOMContentLoaded", loadKeyLines);
import KeyLines from "keylines";
export function data() {
  var purple = "#9767ba";
  var userFontIcon = { t: "fas fa-user", c: "white" };

  var nodeData = {
    a: { fn: "Bob", ln: "Smith" },
    b: { fn: "Diane", ln: "Caliva" },
    c: { fn: "Debbie", ln: "Denise" },
    d: { fn: "Sean", ln: "Young" },
    e: { fn: "Tim", ln: "Angulo" },
    f: { fn: "Bud", ln: "Alper" },
    g: { fn: "Morris", ln: "Chapnick" },
    h: { fn: "Joe", ln: "Turkel" },
    j: { fn: "David", ln: "Snyder" },
    i: { fn: "Michael", ln: "Deeley" },
  };

  var linkData = [
    "a-b",
    "a-c",
    "a-d",
    "a-e",
    "b-c",
    "b-d",
    "c-j",
    "c-i",
    "e-g",
    "h-i",
    "g-e",
    "i-j",
  ];

  var nodes = Object.keys(nodeData).map(function (id) {
    var d = nodeData[id];

    return {
      type: "node",
      id: id,
      t: d.fn,
      c: purple,
      fi: userFontIcon,
      d: d,
    };
  });

  var links = linkData.map(function (id) {
    var ends = id.split("-");
    return {
      type: "link",
      id: id,
      id1: ends[0],
      id2: ends[1],
      a2: true,
      w: 2,
    };
  });

  return {
    type: "LinkChart",
    items: nodes.concat(links),
  };
}
<!doctype html>
<html lang="en" style="background-color: #2d383f">
  <head>
    <meta charset="utf-8" />
    <title>Combo Options</title>
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/keylines.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/minimalsdk.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/sdk-layout.css" />
    <link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/demo.css" />
    <link
      rel="stylesheet"
      type="text/css"
      href="@fortawesome/[email protected]/css/fontawesome.css"
    />
    <link
      rel="stylesheet"
      type="text/css"
      href="@fortawesome/[email protected]/css/solid.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div id="klchart" class="klchart"></div>
    <script type="module" src="./code.js"></script>
  </body>
</html>
table {
  max-width: 100%;
  background-color: transparent;
  border-collapse: collapse;
  border-spacing: 0;
}

.table {
  width: 100%;
  margin-bottom: 20px;
}

.table th,
.table td {
  line-height: 20px;
  text-align: left;
  vertical-align: top;
}

.table td {
  border-top: 1px solid #ddd;
}

.table-condensed th,
.table-condensed td {
  padding: 4px 5px;
}

.table td {
  padding: 6px 16px;
}

.btn-group {
  display: flex;
}

.btn-group > .btn {
  flex: 1;
  min-width: fit-content;
}

input[type="range"] {
  display: block;
}

#arrange-options legend {
  font-size: 14px;
  line-height: 20px;
  margin-bottom: 2px;
}

#arrange-options {
  margin-bottom: 10px;
  gap: 10px;
  display: flex;
  flex-direction: column;
}

Terms of use

These terms do not alter or supersede any existing agreements between you (or your employer) and us.

By accessing or using any Content you agree to be bound by these Terms of Use. Please review these terms carefully before using the website.

The contents of this website, including but not limited to any text, code samples, API references, schemas, interactive tools, and other materials (collectively, the 'Content'), are made available for informational and internal evaluation purposes only. All intellectual property rights in the Content are reserved. No licence is granted to use the Content for any commercial purpose, or to copy, distribute, modify, reverse-engineer, or incorporate any part of the Content into any product or service, without our prior written consent.

This Content is provided “as is” and “as available,” without any representations, warranties, or guarantees of any kind, whether express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, non-infringement, or accuracy. To the fullest extent permitted by applicable law, we expressly exclude and disclaim all implied warranties, conditions, and other terms that might otherwise be implied.

We disclaim all liability for any loss or damage, whether direct, indirect, incidental, consequential, or otherwise, arising from any reliance placed on the Content or from your use of it, to the fullest extent permitted by applicable law. By continuing to access or use the Content, you acknowledge and agree to these terms.