//
// 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
