//
//     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,
  countryComboArrangement,
  countryAliases,
  regionMapping,
  getIcon,
  getComboSelectionStyling,
  getNonComboSelectionStyling,
  getCombineNodeStyle,
  getGlyphStyling,
  getNodeStyle,
  getComboStyle,
} from "./combo2-data.js";

let chart;
let graphEngine;

// State variables
let combinedByCountry = false;
let combinedByRegion = false;

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

// Common helper 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 (isCombo(id, "all")) {
      comboIds.push(id);
    }
  });
  return comboIds;
}
function formatCountry(country) {
  let countryFormatted = country.toLowerCase().replace(/ /g, "-");

  if (countryFormatted in countryAliases) {
    countryFormatted = countryAliases[countryFormatted];
  }
  return countryFormatted;
}

function getCountryGlyph(item) {
  if (!item.d.country || item.d.country === "Unknown") {
    return null;
  }
  const countryFormatted = formatCountry(item.d.country);
  const glyph = getGlyphStyling(countryFormatted);
  return glyph;
}

function getAllNeighbours(ids) {
  const nodeNeighbours = graphEngine.neighbours(
    ids.filter((id) => !isCombo(id, "all")),
  );
  const comboNeighbours = chart
    .graph()
    .neighbours(ids.filter((id) => isCombo(id, "all")));
  const allLinks = nodeNeighbours.links.concat(comboNeighbours.links);
  const allNodes = nodeNeighbours.nodes.concat(comboNeighbours.nodes);
  return {
    links: Array.from(new Set(allLinks).values()),
    nodes: Array.from(new Set(allNodes).values()),
  };
}

function collectRootCombos(neighbours, selectedIds) {
  const roots = new Set();
  neighbours.links.forEach((linkId) => {
    const link = chart.getItem(linkId);
    let root1 = chart.combo().find(link.id1);
    let root2 = chart.combo().find(link.id2);
    if (root1 != null) roots.add(root1);
    if (root2 != null) roots.add(root2);
  });

  neighbours.nodes.forEach((nodeId) => {
    let rootId = chart.combo().find(nodeId);
    if (rootId != null) roots.add(rootId);
  });

  selectedIds.forEach((nodeId) => {
    let rootId = chart.combo().find(nodeId);
    if (rootId != null) roots.add(rootId);
  });

  return roots;
}
/* END of helper 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
  updateSelection([]);
  applyStyling(new Set());
}

// Helper functions for COMBINE action //

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 2 * Math.sqrt(chart.combo().info(id).links.length);
  }
  return 2;
}

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);
}
// Allows links to a closed combo from an open combo
function isLinkedToOpenCombo(linkId) {
  const link = chart.getItem(linkId);
  const [node1, node2] = chart.combo().find([link.id1, link.id2]);
  // Filter out non-combo ends of the link
  const linkEndCombos = [node1, node2].filter((id) => isCombo(id, "all"));
  // Return true only if one of the links is an open combo
  return linkEndCombos.some((comboId) => chart.combo().isOpen(comboId));
}
// END of combine helper functions //

function combineNodes(criteria, arrange) {
  chart.zoom("fit", { animate: true, time: 500 });

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

  const combineArray = [];

  Object.keys(groups).forEach((group) => {
    if (group === "null") {
      return;
    }

    toClose.push(...groups[group]);

    const firstItem = chart.getItem(groups[group][0]);
    const isRegion = firstItem.d.region !== undefined;
    const region = isRegion
      ? firstItem.d.region
      : getRegion(firstItem.d.country);
    const nodeSize = Math.sqrt(getNodeSize(groups[group]));
    const icon = getIcon(isRegion ? region : "default");

    const countryGlyph = !isRegion ? getCountryGlyph(firstItem) : null;
    const g = countryGlyph !== null ? [countryGlyph] : null;

    const combineIds = {
      ids: groups[group],
      d: { region, isRegion },
      glyph: null,
      style: getCombineNodeStyle(firstItem, isRegion, region, icon, nodeSize, g)
        .closedStyle,
      openStyle: getCombineNodeStyle(firstItem, isRegion, region).openStyle,
    };

    combineArray.push(combineIds);
  });

  // close all groups before we combine
  chart.combo().close(toClose, { animate: false });
  return chart.combo().combine(combineArray, options).then(applyLinkTheme);
}
/* END of combine action controls */

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 combineCountries() {
  enableInput(
    ["combineCountry", "combineRegion", "uncombine", "openall", "layout"],
    false,
  );
  combinedByCountry = true;
  combineNodes(byCountry, countryComboArrangement).then(afterCombine);
}

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

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 (!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 chart
    .combo()
    .arrange(ids, { tightness: 6.2 })
    .then(() => {
      openOrCloseCombo(ids, true, cb);
    });
}

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

function uncombineAll() {
  const combos = [];
  chart.each({ type: "node", items: "toplevel" }, (node) => {
    if (isCombo(node.id, "all")) {
      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);
          applyStyling(new Set());
        });
      });
  }
}

function onSelection() {
  // Get selection state for links and nodes
  const selectedNodeIds = chart.selection();
  const highlightedItemIds = updateSelection(selectedNodeIds);
  applyStyling(highlightedItemIds);
}

function updateSelection(selectedIds) {
  let highlightedItemIds;
  // clear revealed items
  chart.combo().reveal([]);

  if (selectedIds.length > 0) {
    highlightedItemIds = foregroundSelected(selectedIds);
  } else {
    // Nothing is selected, reset foregrounding so we see everything
    chart.foreground(() => true, { type: "all" });
    chart.combo().reveal([]);
    highlightedItemIds = new Set();
  }

  return highlightedItemIds;
}

function foregroundSelected(selectedNodeIds) {
  const highlightedItemIds = new Set();
  const itemStyleUpdates = [];
  const neighbours = getAllNeighbours(selectedNodeIds);
  // Disaggregate links into combos
  chart.combo().reveal(neighbours.links.filter(isLinkedToOpenCombo));

  // Update links styles
  neighbours.links.forEach((linkId) => {
    highlightedItemIds.add(linkId);
    itemStyleUpdates.push({ id: linkId, c: theme.selectedLinkColour });
  });

  // Update node styles
  selectedNodeIds.forEach((id) => {
    highlightedItemIds.add(id);
    const item = chart.getItem(id);
    const updatedStyle = isCombo(id, "all")
      ? getComboSelectionStyling(item)
      : getNonComboSelectionStyling(item);
    itemStyleUpdates.push(updatedStyle);
  });

  // Collect all the things that should be in the foreground
  const itemsToForeground = new Set(
    selectedNodeIds.concat(neighbours.links).concat(neighbours.nodes),
  ).union(collectRootCombos(neighbours, selectedNodeIds));

  if (selectedNodeIds.every((id) => !isCombo(id, "node"))) {
    // Where only nodes are selected, use the underlying items
    chart.foreground((item) => itemsToForeground.has(item.id), {
      type: "all",
      items: "underlying",
    });
  } else {
    // Either a mixture of combos and nodes or only combos selected, use the top level instead
    chart.foreground((item) => itemsToForeground.has(item.id), {
      type: "all",
      items: "toplevel",
    });
  }

  chart.setProperties(itemStyleUpdates);
  return highlightedItemIds;
}

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" });
    }
  });
  chart.on("click", ({ id, preventDefault }) => {
    const item = chart.getItem(id);
    if (item != null && item.type === "link") {
      preventDefault();
    }
  });
  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 applyStyling(highlightedItemIds) {
  const props = [];
  chart.each({ items: "all" }, (item) => {
    if (highlightedItemIds.has(item.id)) {
      // An update is not required
      return;
    }
    if (item.type === "node") {
      if (!isCombo(item.id)) {
        const countryGlyph = getCountryGlyph(item);

        props.push(getNodeStyle(item, countryGlyph));
      } else if (isCombo(item.id)) {
        props.push(getComboStyle(item));
      }
    } else if (isCombo(item.id, "link")) {
      props.push({ id: item.id, c: null });
    } else if (item.type === "link") {
      // non-combo link styles
      props.push({ id: item.id, c: theme.linkColour, w: 3 });
    }
  });

  chart.setProperties(props);
}

async function startKeyLines() {
  function getImageAlignments() {
    const imageAlignments = {
      "fas fa-user": { dy: -2, e: 0.7 },
      "fas fa-users": { dy: 0, e: 0.6 },
      "fas fa-globe": { dy: 3, e: 1.5 },
      "fas fa-earth-americas": { dy: 3, e: 1.5 },
      "fas fa-earth-asia": { dy: 3, e: 1.5 },
      "fas fa-earth-africa": { dy: 3, e: 1.5 },
      "fas fa-earth-europe": { dy: 3, e: 1.5 },
    };
    const countries = Object.keys(regionMapping);
    const countriesFormatted = countries.map((country) =>
      formatCountry(country),
    );

    // Set image alignment for country glyphs
    countriesFormatted.forEach(
      (countryFormatted) =>
        (imageAlignments[`/im/flag-icons/${countryFormatted}.svg`] = {
          e: 1.3,
        }),
    );

    return imageAlignments;
  }

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

  await chart.load(data);
  graphEngine.load(chart.serialize());
  layout();
  setUpEventHandlers();

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

function loadKeyLines() {
  // load FontAwesome for the node icons
  document.fonts.load('900 24px "Font Awesome 6 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/fontawesome6/fontawesome.css">
    <link rel="stylesheet" type="text/css" href="/css/fontawesome6/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>Use the controls to organise nodes into combos based on their custom data properties.</p>
                <p>Double-click the combos to see the people inside and select them 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