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