//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Explore how combos affect paths between nodes.
import KeyLines from "keylines";
import {
  data,
  combos,
  linkWidth,
  colours,
  openComboStyle,
} from "./comboshortestpath-data.js";

let chart;
let graph;
let toNode = null;
let fromNode = null;
let itemsToHighlight = [];

const pathTypeElements = document.querySelectorAll(".pathType");

function isTopLevelMode() {
  return (
    document.querySelector(".pathType.active").dataset.value === "toplevel"
  );
}

function getStyle(item, highlighted) {
  const style = { id: item.id };

  const isCombo = chart.combo().isCombo(item.id);
  if (item.type === "node") {
    if (isCombo) {
      style.oc = highlighted ? { b: colours.highlight, bw: 5 } : openComboStyle;
    } else {
      style.b = highlighted ? colours.highlight : null;
    }
  } else if (item.type === "link") {
    if (highlighted) {
      style.c = colours.highlight;
      style.w = 5;
    } else {
      style.c = isCombo ? colours.combolink : colours.link;
      style.w = linkWidth;
    }
  }
  return style;
}

/** Demo styling & highlighting * */

function updateHighlight(items) {
  // Clear styles on currently highlighted items
  const clear = itemsToHighlight.map((item) => getStyle(item, false));
  // Set styles on newly highlighted items (may override cleared styles but this is fine)
  const highlight = items.map((item) => getStyle(item, true));

  // Update chart items
  chart.setProperties(clear.concat(highlight));

  // Save the list of currently highlighted items
  itemsToHighlight = items;
}

// Check whether the node is 'visible' to the shortest path calculation
function isNodeVisible(node) {
  if (isTopLevelMode()) {
    // If we're in top mode, any nodes not inside combos are visible
    return !chart.combo().find(node.id);
  }
  // If we're in underlying node, any combos are not visible
  return !chart.combo().isCombo(node.id);
}

function clearSelection() {
  fromNode = null;
  toNode = null;
  chart.selection([]);
  updateHighlight([]);
}

/** Shortest path calculations * */

function calculateShortestPath() {
  // Only calculate the path if valid nodes are selected
  if (fromNode && toNode && isNodeVisible(fromNode) && isNodeVisible(toNode)) {
    let pathIds;
    if (isTopLevelMode()) {
      // Use the chart's model to find the shortest paths (Without looking inside combos)
      pathIds = chart.graph().shortestPaths(fromNode.id, toNode.id).items;

      // For each item in the path, get its top level parent (ie, outer combo)
      pathIds = pathIds.map((id) => chart.combo().find(id) || id);
    } else {
      // Use the graph engine to find shortest paths (ignoring combo nodes)
      pathIds = graph.shortestPaths(fromNode.id, toNode.id).items;
    }

    // Get the nodes on the path for highlighting
    const items = chart.getItem(pathIds);

    // Highlight items on the path
    updateHighlight(items);
  } else {
    clearSelection();
  }
}

function onSelectionChanged() {
  const nodes = [fromNode, toNode].filter((node) => node !== null);
  chart.selection(nodes.map((node) => node.id));
  if (nodes.length === 2) {
    calculateShortestPath();
  } else {
    updateHighlight(nodes);
  }
}

function selectNode(node) {
  // Don't re-select the node we just selected
  if (fromNode && fromNode.id === node.id) {
    return;
  }

  toNode = fromNode;
  fromNode = node;
  onSelectionChanged();
}

/** Demo Logic * */

// Update the set of revealed links
// If we're calculating underlying paths, reveal the underling links
// If we're calculating top paths, we clear the reveal with an empty array
function updateReveal() {
  const reveal = [];
  if (!isTopLevelMode()) {
    chart.each({ type: "link", items: "underlying" }, (link) => {
      reveal.push(link.id);
    });
  }
  chart.combo().reveal(reveal);
}

function getNearestVisibleNode(item) {
  if (isTopLevelMode()) {
    const parent = chart.combo().find(item.id);
    if (parent) {
      return chart.getItem(parent);
    }
  } else if (chart.combo().isCombo(item.id)) {
    return null;
  }
  if (item.type !== "node") {
    return null;
  }
  return item;
}

function handleSelect(id) {
  if (id === null) {
    // click on background - deselect all
    clearSelection();
  } else {
    const item = chart.getItem(id);
    if (item) {
      const nearest = getNearestVisibleNode(item);
      if (nearest) {
        selectNode(nearest);
      }
    }
  }
}

function setupEvents() {
  pathTypeElements.forEach((element) => {
    element.addEventListener("click", () => {
      pathTypeElements.forEach((e) => {
        e.classList.remove("active");
        e.classList.remove("btn-kl");
      });
      element.classList.add("active");
      element.classList.add("btn-kl");
      updateReveal();
      calculateShortestPath();
    });
  });

  chart.on("pointer-down", ({ id, button, preventDefault }) => {
    if (button === 0) {
      handleSelect(id);
      // Prevent KeyLines selecting the item
      preventDefault();
    }
  });

  // Prevent marquee dragging
  chart.on("drag-start", ({ preventDefault }) => preventDefault());

  // Prevent combos from being opened or closed
  chart.on("double-click", ({ preventDefault }) => preventDefault());
}

async function startKeyLines() {
  const options = {
    logo: { u: "/images/Logo.png" },
    selectedNode: {},
    navigation: { shown: false },
  };

  chart = await KeyLines.create({ container: "klchart", options });

  setupEvents();

  // data is defined in comboshortestpath-data.js
  graph = KeyLines.getGraphEngine();
  graph.load(data);
  chart.load(data);

  await chart
    .combo()
    .combine(combos, { select: false, animate: false, arrange: "concentric" });
  // Style combo links a little differently to normal links, using a regex-based property setter
  chart.setProperties(
    { id: "_combolink_", c: colours.combolink, w: linkWidth },
    true
  );

  // Zoom the chart to fit
  chart.zoom("fit", { animate: false });

  // Set a nice default path
  selectNode(chart.getItem("a"));
  selectNode(chart.getItem("b"));
}

window.addEventListener("DOMContentLoaded", startKeyLines);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
  <head>
    <meta charset="UTF-8">
    <title>Combos: Find Paths</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">
    <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="/comboshortestpath.js" crossorigin="use-credentials" 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%;">Combos: Find Paths</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">
                <fieldset>
                  <p>Select two nodes to show the shortest path between them. </p>
                  <p>You can specify how the chart should be traversed, which will generate different results.</p>
                  <div class="btn-group" style="margin-bottom: 16px">
                    <input class="pathType btn btn-kl active" type="button" value="Top Down" data-value="toplevel">
                    <input class="pathType btn" type="button" value="Bottom Up" data-value="underlying">
                  </div>
                </fieldset>
                <fieldset>
                  <p>Orange links show the shortest path between the two selected nodes. </p>
                  <p>Blue links represent the underlying links.</p>
                  <p>Grey links represent connections between nodes across combos.</p>
                </fieldset>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
Loading source