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