//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//!    Combine similar nodes to simplify networks.
import KeyLines from "keylines";
import { data } from "./combo-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);
<!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="/css/keylines.css">
    <link rel="stylesheet" type="text/css" href="/css/minimalsdk.css">
    <link rel="stylesheet" type="text/css" href="/css/sdk-layout.css">
    <link rel="stylesheet" type="text/css" href="/css/demo.css">
    <link rel="stylesheet" type="text/css" href="/combo.css">
    <link rel="stylesheet" type="text/css" href="/css/fontawesome5/fontawesome.css">
    <link rel="stylesheet" type="text/css" href="/css/fontawesome5/solid.css">
    <script src="/vendor/jquery.js" defer type="text/javascript"></script>
    <script id="keylines" src="/public/keylines.umd.cjs" defer type="text/javascript"></script>
    <script src="/combo.js" crossorigin="use-credentials" defer type="module"></script>
  </head>
  <body>
    <div class="chart-wrapper demo-cols">
      <div class="tab-content-panel flex1" id="lhs" data-tab-group="rhs">
        <div class="toggle-content is-visible" id="lhsVisual" style="width:100%; height: 100%;">
          <div class="klchart" id="klchart">
          </div>
        </div>
      </div>
      <div class="rhs citext closed" id="demorhscontent">
        <div class="title-bar">
          <svg viewBox="0 0 360 60" style="max-width: 360px; max-height: 60px; font-size: 24px; font-family: Raleway; flex: 1;">
            <text x="0" y="38" style="width: 100%;">Combo Options</text>
          </svg>
        </div>
        <div class="tab-content-panel" data-tab-group="rhs">
          <div class="toggle-content is-visible tabcontent" id="controlsTab">
            <form autocomplete="off" onsubmit="return false" id="rhsForm">
              <div class="cicontent">
                <p>
                  Multi-select nodes and click <b>Combine</b> to create a combo.
                  Try different interactions:
                </p>
                <ul>
                  <li>Double-click combos to open or close them</li>
                  <li>Multi-select nodes and combos and create nested combos</li>
                  <li>Uncombine nested combos in sequence or all at once</li>
                </ul>
                <p>Change the look of open combos with the options below.</p>
                <div class="opt-container" name="shape">
                  <legend>Shape</legend>
                  <div class="btn-group">
                    <button class="btn active" value="circle">Circle</button>
                    <button class="btn" value="rectangle">Rectangle</button>
                  </div>
                </div>
                <div class="opt-container" name="arrange">
                  <legend>Arrangement</legend>
                  <div class="btn-group">
                    <button class="btn active" value="lens">Lens</button>
                    <button class="btn" value="concentric">Concentric</button>
                    <button class="btn" value="grid">Grid</button>
                    <button class="btn" value="sequential">Sequential</button>
                  </div>
                </div>
                <legend>Arrangement Options</legend>
                <div id="arrange-options">
                  <div class="opt-container" name="tightness">
                    <legend>Tightness: <span class="value">5</span></legend>
                    <input type="range" min="0" max="10" value="5">
                  </div>
                  <div class="opt-container" name="gridShape" style="display: none;">
                    <legend>Grid shape</legend>
                    <div class="btn-group">
                      <button class="btn active" value="auto">Auto</button>
                      <button class="btn" value="row">Row </button>
                      <button class="btn" value="col">Column</button>
                    </div>
                  </div>
                  <div class="opt-container" name="stretch" style="display: none;">
                    <legend>Stretch: <span class="value">1</span></legend>
                    <input type="range" min="0" max="2" value="1" step="0.1">
                  </div>
                  <div class="opt-container" name="orientation" style="display: none;">
                    <legend>Orientation  </legend>
                    <div class="btn-group">
                      <button class="btn active" value="right">Right</button>
                      <button class="btn" value="down">Down</button>
                      <button class="btn btn" value="left">Left</button>
                      <button class="btn btn" value="up">Up</button>
                    </div>
                  </div>
                </div>
                <div>
                  <input class="btn layout disabled" type="button" value="Combine" id="combine">
                  <input class="btn layout disabled" type="button" value="Uncombine" id="uncombine">
                  <input class="btn layout" type="button" value="Layout" id="layout">
                </div>
                <label class="checkbox" style="margin-top:10px;">
                  <input id="uncombineAll" type="checkbox"><span>&nbsp;Uncombine all nested levels</span>
                </label>
                <div></div>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </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;
}
Loading source