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