//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//!    Combine nodes to reveal patterns in complex networks.
import KeyLines from "keylines";
import {
  data,
  getRegion,
  theme,
  getRegionTheme,
  countryComboArrangement,
  countryAliases,
} from "./combo2-data.js";

let chart;
let graph;
let isDragging = false;
let combinedByCountry = false;
let combinedByRegion = false;

// set of comboids which are currently being opened/closed
const comboAnimations = {};

// Common SUPPORT Functions //

function isCombo(ids, type = "node") {
  return chart.combo().isCombo(ids, { type });
}

function layout(mode) {
  return chart.layout("organic", { mode });
}

function getNodeLinks(nodeId) {
  return chart.graph().neighbours(nodeId).links;
}

function getAllComboIds() {
  const comboIds = [];
  chart.each({ type: "node", items: "all" }, ({ id }) => {
    if (chart.combo().isCombo(id)) {
      comboIds.push(id);
    }
  });
  return comboIds;
}

function foregroundSelection(ids) {
  if (ids.length === 0) {
    // restore all the elements in the foreground
    chart.foreground(() => true, { type: "all" });
    // clear revealed items
    chart.combo().reveal([]);
  } else {
    // find the connections for all of the selected ids
    const neighbours = graph.neighbours(ids);

    const foregroundMap = {};
    const linksToReveal = [];
    const propsToUpdate = [];

    neighbours.links.forEach((linkId) => {
      // build map of neighbouring links to foreground
      foregroundMap[linkId] = true;
      // add neighbouring links to reveal array
      linksToReveal.push(linkId);
    });
    neighbours.nodes.forEach((nodeId) => {
      // add neighbouring nodes to foreground map
      foregroundMap[nodeId] = true;
    });

    const selectedItems = chart.getItem(ids);
    selectedItems.forEach((item) => {
      // add selected items to foreground map
      foregroundMap[item.id] = true;
      if (item.type === "link") {
        // add only the selected links to the reveal array
        linksToReveal.push(item.id);
      }
    });

    // run foreground on underlying links and nodes
    chart.foreground((item) => foregroundMap[item.id], { type: "all" });

    // reveal the links
    chart.combo().reveal(linksToReveal);

    // background all combolinks
    chart
      .combo()
      .find(linksToReveal, { parent: "first" })
      .forEach((id) => {
        if (id !== null && chart.getItem(id).type === "link") {
          propsToUpdate.push({ id, bg: true });
        }
      });

    chart.setProperties(propsToUpdate);
  }
}

// END OF SUPPORT FUNCTIONS //

// This code controls the COMBINE action //

function enableInput(ids, enabled) {
  ids.forEach((id) => {
    const button = document.getElementById(id);
    if (enabled) {
      button.classList.remove("disabled");
      button.removeAttribute("disabled");
    } else {
      button.classList.add("disabled");
      button.setAttribute("disabled", "");
    }
  });
}

function afterCombine() {
  layout("adaptive").then(() => {
    enableInput(["openall", "combineRegion", "uncombine", "layout"], true);
    enableInput(["combineRegion"], !combinedByRegion);
  });

  // reset foregrounded items when nodes are combined
  foregroundSelection([]);
  applyTheme();
}

// Support functions for the COMBINE flow

function getNodeSize(ids) {
  let size = 0;
  for (let i = 0; i < ids.length; i++) {
    if (isCombo(ids[i])) {
      size += chart.combo().info(ids[i]).nodes.length;
    } else {
      // regular node
      size += 1;
    }
  }
  return size;
}

function getLinkSize(id) {
  if (isCombo(id, "link")) {
    // set the link thickness
    return 3 * Math.sqrt(chart.combo().info(id).links.length);
  }
  return 3;
}

function groupNodesBy(criteria) {
  const groups = {};
  chart.each({ type: "node", items: "toplevel" }, (item) => {
    const group = criteria(item);
    if (group) {
      if (!groups[group]) {
        groups[group] = [];
      }
      groups[group].push(item.id);
    }
  });
  return groups;
}

function applyLinkTheme(comboIds) {
  const props = getNodeLinks(comboIds).map((id) => ({
    id,
    w: getLinkSize(id),
  }));
  return chart.setProperties(props, false);
}

function getCountryGlyph(item) {
  if (!item.d.country || item.d.country === "Unknown Country") {
    return null;
  }
  let countryFormatted = item.d.country.toLowerCase().replace(/ /g, "-");
  if (countryFormatted in countryAliases) {
    countryFormatted = countryAliases[countryFormatted];
  }
  return { p: "ne", u: `/im/flag-icons/${countryFormatted}.svg` };
}

function combine(criteria, arrange) {
  const options = { arrange, animate: true, time: 1500, select: false };
  const groups = groupNodesBy(criteria);
  const toClose = [];

  const combineArray = [];
  Object.keys(groups).forEach((group) => {
    toClose.push(...groups[group]);

    // ignore the 'unknown region'
    if (group !== "Unknown Region") {
      const firstItem = chart.getItem(groups[group][0]);
      const isRegion = firstItem.d.region !== undefined;
      const region = isRegion
        ? firstItem.d.region
        : getRegion(firstItem.d.country);
      const rTheme = getRegionTheme(region);

      const combineIds = {
        ids: groups[group],
        d: { region, isRegion },
        label: group,
        glyph: null,
        style: {
          e: Math.sqrt(getNodeSize(groups[group])),
          c: isRegion ? "white" : rTheme.iconColour,
          fc: "rgb(100,100,100)",
          fs: isRegion ? theme.regionFontSize : theme.countryFontSize,
          fi: {
            t: isRegion ? "fas fa-globe-americas" : "fas fa-users",
            c: isRegion ? rTheme.iconColour : "white",
          },
          bw: 2,
          b: isRegion ? null : theme.borderColour,
          sh: chart.options().combos.shape === "rectangle" ? "box" : undefined,
        },
        openStyle: {
          c: isRegion ? rTheme.regionOCColour : rTheme.countryBgColour,
          b: theme.borderColour,
          bw: 5,
        },
      };

      combineArray.push(combineIds);
    }
  });

  // close all groups before we combine
  chart.combo().close(toClose, { animate: false });

  return chart.combo().combine(combineArray, options).then(applyLinkTheme);
}

function byCountry(item) {
  return item.d.country || null;
}

function byRegion(item) {
  return item.d.region || null;
}

function getRegionArrangement() {
  return chart.options().combos.shape === "rectangle" ? "grid" : "concentric";
}

function layoutAllCombos() {
  const combos = getAllComboIds().map(chart.getItem);
  const regionCombos = combos
    .filter((item) => item.d.isRegion)
    .map((a) => a.id);
  const countryCombos = combos
    .filter((item) => !item.d.isRegion)
    .map((a) => a.id);

  Promise.all([
    chart.combo().arrange(regionCombos, {
      name: getRegionArrangement(),
    }),
    chart.combo().arrange(countryCombos, {
      name: countryComboArrangement,
    }),
  ]).then(() => layout("adaptive"));
}

function combineCountries() {
  enableInput(
    ["combineCountry", "combineRegion", "uncombine", "openall", "layout"],
    false
  );
  combinedByCountry = true;
  combine(byCountry, countryComboArrangement).then(afterCombine);
}

function combineRegions() {
  const regionArrange = getRegionArrangement();
  enableInput(
    ["combineCountry", "combineRegion", "uncombine", "openall", "layout"],
    false
  );
  combinedByRegion = true;
  if (combinedByCountry) {
    combine(byRegion, regionArrange).then(afterCombine);
  } else {
    combine(byCountry, countryComboArrangement).then(() => {
      combine(byRegion, regionArrange).then(afterCombine);
    });
  }
}

function openOrCloseCombo(ids, open, cb) {
  if (Object.keys(comboAnimations).length > 0) {
    return false;
  }

  const action = open ? chart.combo().open : chart.combo().close;
  let targets = Array.isArray(ids) ? ids : [ids];

  targets = targets.filter((id) => {
    if (!chart.combo().isCombo(id) || chart.combo().isOpen(id) === open) {
      return false;
    }
    comboAnimations[id] = true;
    return true;
  });

  action(targets, { adapt: "inCombo", time: 300 })
    .then(() => (targets.length > 0 ? layout("adaptive") : null))
    .then(() => {
      targets.forEach((id) => {
        delete comboAnimations[id];
      });
      if (cb) {
        cb();
      }
    });
  return targets.length > 0;
}

function openCombo(ids, cb) {
  return openOrCloseCombo(ids, true, cb);
}

function closeCombo(ids, cb) {
  return openOrCloseCombo(ids, false, cb);
}

// End of COMBINE action //

function uncombineAll() {
  const combos = [];
  chart.each({ type: "node", items: "toplevel" }, (node) => {
    if (chart.combo().isCombo(node.id)) {
      combos.push(node.id);
    }
  });
  if (combos.length) {
    enableInput(["uncombine", "openall", "layout"], false);
    chart
      .combo()
      .uncombine(combos, { full: true, select: false })
      .then(() => {
        layout("adaptive").then(() => {
          combinedByCountry = false;
          combinedByRegion = false;
          enableInput(["combineCountry", "combineRegion", "layout"], true);
          applyTheme();
        });
      });
  }
}

function onSelection() {
  // grab current selection
  const selectedIds = chart.selection();
  // filter out any combo items to get only the underlying selection
  const ids = selectedIds.filter((id) => !chart.combo().isCombo(id));
  // remove the combos from the selection
  chart.selection(ids);
  // foreground the filtered selection of items and their connections
  foregroundSelection(ids);
}

function setUpEventHandlers() {
  chart.on("selection-change", onSelection);
  chart.on("drag-start", ({ type, id, setDragOptions }) => {
    if (
      type === "node" &&
      chart.combo().isOpen(id) &&
      !chart.options().handMode
    ) {
      setDragOptions({ type: "marquee" });
    }
    isDragging = true;
  });
  chart.on("drag-end", () => {
    isDragging = false;
  });
  chart.on("double-click", ({ id, preventDefault, button }) => {
    if (id && button === 0) {
      if (isCombo(id)) {
        if (chart.combo().isOpen(id)) {
          closeCombo(id);
        } else {
          openCombo(id);
        }
      }
      preventDefault();
    }
  });
  // buttons
  document
    .getElementById("combineCountry")
    .addEventListener("click", combineCountries);
  document
    .getElementById("combineRegion")
    .addEventListener("click", combineRegions);
  document.getElementById("uncombine").addEventListener("click", uncombineAll);
  document.getElementById("layout").addEventListener("click", () => layout());
  document.getElementById("openall").addEventListener("click", () => {
    openCombo(getAllComboIds());
  });
}

function applyTheme() {
  const props = [];
  chart.each({ type: "node" }, (item) => {
    const rTheme = getRegionTheme(getRegion(item.d.country));
    const countryGlyph = getCountryGlyph(item);
    const g = countryGlyph !== null ? [countryGlyph] : [];

    props.push({
      id: item.id,
      u: null,
      g,
      c: rTheme.iconColour,
      fi: { t: "fas fa-user", c: "white" },
      b: theme.borderColour,
      bw: 2,
    });
  });

  // node styles
  chart.setProperties(props);
  // link styles
  chart.setProperties({ id: "p", c: theme.linkColour, w: 3 }, true /* regex */);
}

async function startKeyLines() {
  const imageAlignment = {
    "fas fa-user": { dy: -10, e: 0.9 },
    "fas fa-users": { dy: 0, e: 0.8 },
    "fas fa-globe-americas": { dy: 0, e: 1.4 },
  };

  chart = await KeyLines.create({
    container: "klchart",
    options: {
      drag: {
        links: false,
      },
      truncateLabels: { maxLength: 15 },
      imageAlignment,
      selectedNode: theme.selectedNode,
      selectedLink: theme.selectedLink,
      logo: { u: "/images/Logo.png" },
      iconFontFamily: "Font Awesome 5 Free",
      linkEnds: { avoidLabels: false },
      minZoom: 0.02,
      handMode: true,
    },
  });

  chart.load(data);
  graph = KeyLines.getGraphEngine();
  // load the raw items in to the graph engine
  graph.load(chart.serialize());
  applyTheme();
  layout();
  setUpEventHandlers();

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

function loadKeyLines() {
  // load FontAwesome for the node icons
  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>Combining Nodes</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="/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="/combo2-data.js" crossorigin="use-credentials" defer type="module"></script>
    <script src="/combo2.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%;">Combining Nodes</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>Organise the data into combos using the controls below and double-click the combos to see what’s inside. </p>
                <p>Click on a person inside the combo to reveal their connections.</p>
                <div>
                  <input class="btn btn-block" type="button" value="Combine by Country" style="text-align: center;" id="combineCountry">
                  <input class="btn btn-block" type="button" value="Combine by Country and Region" style="text-align: center; margin-top:12px;" id="combineRegion">
                  <input class="btn btn-block" type="button" value="Uncombine all combos" style="text-align: center; margin-top:12px;" disabled id="uncombine">
                  <input class="btn btn-block" type="button" value="Open all combos" style="display: inline; margin-top:12px;" disabled id="openall">
                  <input class="btn btn-block" type="button" value="Layout" style="text-align: center; margin-top:12px;" id="layout">
                </div>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
Loading source