//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Disrupt a resilient mafia network through social network analysis.
import KeyLines from "keylines";
import { data } from "./mafia-data.js";
let visibleChart;
let hiddenChart;
let savedChartItems;
let nodeSelection = [];
let nodeIdsRemovedBySlider = [];
let allNodeIds = [];
const familyIdsCache = {};
const allNodeIdsLookup = {};
// Stacks to maintain the order of items filtered by manual selection and the slider
let manuallyRemovedStack = [];
let sliderStack = [];
// Get elements from the UI
const familyCheckBoxesEls = [
...document.querySelectorAll("input[type=checkbox]"),
];
const largestCompSizeEl = document.getElementById("lcc");
const restoreItemsButtonEl = document.getElementById("restore");
const removeItemsButtonEl = document.getElementById("remove");
const sliderEl = document.getElementById("slider");
const resetAllEl = document.getElementById("resetAll");
function setButtonAvailability(element, available) {
element.classList[available ? "add" : "remove"](["active", "btn-kl"]);
element.disabled = !available;
}
function setUIAvailability(available) {
if (manuallyRemovedStack.length) {
setButtonAvailability(restoreItemsButtonEl, available);
}
familyCheckBoxesEls.forEach((radio) => {
radio.disabled = !available;
});
sliderEl.disabled = !available;
resetAllEl.disabled = !available;
}
// Update order of available nodes for the slider
function setIdsForSliderStack() {
const sortedAvailableNodeIds = allNodeIds
// Available nodes are those on the visible chart
.filter(
(nodeId) =>
allNodeIdsLookup[nodeId].sliderAvailability &&
allNodeIdsLookup[nodeId].filterState
)
.sort(
(idA, idB) => allNodeIdsLookup[idB].index - allNodeIdsLookup[idA].index
);
// Append items already filtered by the slider to the end of the stack, in order they were removed
sliderStack = sortedAvailableNodeIds.concat(nodeIdsRemovedBySlider);
}
function setLargestComponentSize() {
const components = visibleChart.graph().components();
largestCompSizeEl.innerText = components.length
? components.reduce((a, b) => (b.nodes.length < a.nodes.length ? a : b))
.nodes.length
: 0;
}
function setNodeState(nodeIds, property, state) {
nodeIds.forEach((id) => {
allNodeIdsLookup[id][property] = state;
});
}
function setNodesManuallySelected(newSelectedNodes) {
// Clear the previously selected nodes
if (nodeSelection.length) {
setNodeState(nodeSelection, "filterState", true);
nodeSelection = [];
}
if (newSelectedNodes.length) {
// Prepare selected nodes to be removed
nodeSelection = newSelectedNodes;
setNodeState(newSelectedNodes, "filterState", false);
// Highlight neighbours of selected items
const neighbours = visibleChart.graph().neighbours(newSelectedNodes).nodes;
visibleChart.foreground(
(node) =>
newSelectedNodes.includes(node.id) || neighbours.includes(node.id)
);
// Allow selected nodes to be removed
setButtonAvailability(removeItemsButtonEl, true);
} else {
// No nodes selected
visibleChart.selection([]);
visibleChart.foreground(() => true);
setButtonAvailability(removeItemsButtonEl, false);
}
}
async function pingNodes(allNodeIdsToRemove, primaryNodeIds) {
const collateralNodeIdsToRemove = allNodeIdsToRemove.filter(
(id) => !primaryNodeIds.includes(id)
);
if (collateralNodeIdsToRemove.length) {
// Primary removed nodes ping blue
visibleChart.ping(allNodeIdsToRemove, { time: 1200, c: "#5d81f8" });
// Collateral nodes ping red
await visibleChart.ping(collateralNodeIdsToRemove, {
time: 1200,
c: "#FF0000",
});
} else {
await visibleChart.ping(allNodeIdsToRemove, { time: 1200, c: "#5d81f8" });
}
}
async function getItemsFromFilter() {
function matchFilter(node) {
return (
allNodeIdsLookup[node.id].familyCheckbox &&
allNodeIdsLookup[node.id].filterState
);
}
// Filter the hidden chart and return the items to be removed or expanded into visible chart
const { shown, hidden } = await hiddenChart.filter(matchFilter, {
type: "node",
animate: false,
hideSingletons: true,
});
const nodesShown = shown.nodes;
const nodesHidden = hidden.nodes;
const itemsShown = nodesShown.length ? nodesShown.concat(shown.links) : [];
const itemsHidden = nodesHidden.length
? nodesHidden.concat(hidden.links)
: [];
return {
nodesShown,
nodesHidden,
itemsShown,
itemsHidden,
};
}
async function doFiltering(filteredBy) {
// Disable the UI while visible chart is updated
setUIAvailability(false);
// Get items to be expanded or removed
const {
nodesShown: nodeIdsToExpand,
nodesHidden: nodeIdsToRemove,
itemsShown: itemIdsToExpand,
itemsHidden: itemIdsToRemove,
} = await getItemsFromFilter();
if (itemIdsToExpand.length) {
// Allow expanded items to be available for filtering on the slider
setNodeState(nodeIdsToExpand, "sliderAvailability", true);
// Retrieve saved item so we can expand with the correct node size
const itemsToExpand = savedChartItems.filter((item) =>
itemIdsToExpand.includes(item.id)
);
await visibleChart.expand(itemsToExpand, {
layout: { name: "organic", fit: true },
});
} else if (itemIdsToRemove.length) {
let primaryNodesToHide;
// Check which filter was used and identify primary nodes for correct ping colour
if (filteredBy === "selected") {
manuallyRemovedStack.push(nodeIdsToRemove);
primaryNodesToHide = nodeSelection;
// Remove the node selection so that the remove animation is consistent with other filters
nodeSelection = [];
setNodesManuallySelected([]);
} else if (["batanesi", "mistretta", "none"].includes(filteredBy)) {
primaryNodesToHide = familyIdsCache[filteredBy];
} else if (filteredBy === "slider") {
primaryNodesToHide = nodeIdsRemovedBySlider;
}
// Make removed nodes unavailable to the slider
setNodeState(nodeIdsToRemove, "sliderAvailability", false);
// Ping nodes to be removed
await pingNodes(nodeIdsToRemove, primaryNodesToHide);
// Remove items from the visible chart
await visibleChart.removeItem(nodeIdsToRemove);
// Layout items
await visibleChart.layout("organic", { mode: "adaptive" });
}
// Update the slider for the nodes remaining on the visible chart
setIdsForSliderStack();
// Update the largest component indicator
setLargestComponentSize();
// Enable the UI after the visible chart has been updated
setUIAvailability(true);
}
function initialiseInteractions() {
const sliderValueEl = document.getElementById("sliderValue");
// Filter the chart when any checkbox is changed
familyCheckBoxesEls.forEach((checkbox) => {
checkbox.addEventListener("click", (event) => {
let familyIdsToBeFiltered;
const familyName = event.target.id;
const familyChecked = event.target.checked;
setNodesManuallySelected([]);
// Check if family has been filtered before
if (familyIdsCache[familyName]) {
familyIdsToBeFiltered = familyIdsCache[familyName];
} else {
// Get the family ids and cache the ids for later use
familyIdsToBeFiltered = allNodeIds.filter(
(id) => allNodeIdsLookup[id].family === familyName
);
familyIdsCache[familyName] = familyIdsToBeFiltered;
}
setNodeState(familyIdsToBeFiltered, "familyCheckbox", familyChecked);
doFiltering(familyName);
});
});
visibleChart.on("selection-change", () => {
const selectedItems = visibleChart.selection();
// Filter selection to include only nodes
const newSelectedNodes = selectedItems.filter((id) => !id.match(/-/));
setNodesManuallySelected(newSelectedNodes);
});
// Remove manually selected nodes
removeItemsButtonEl.addEventListener("click", async () => {
setButtonAvailability(removeItemsButtonEl, false);
await doFiltering("selected");
setButtonAvailability(restoreItemsButtonEl, true);
});
// Restore previously removed nodes
restoreItemsButtonEl.addEventListener("click", () => {
setNodesManuallySelected([]);
// Update state of nodes to be restoreed
setNodeState(manuallyRemovedStack.pop(), "filterState", true);
// Disable the restore button if stack is empty
if (manuallyRemovedStack.length === 0) {
setButtonAvailability(restoreItemsButtonEl, false);
}
doFiltering();
});
// Update the slider value before the change event if it is dragged
sliderEl.addEventListener("input", () => {
sliderValueEl.innerText = +sliderEl.value;
});
// Filter the chart once the slider value has been changed
sliderEl.addEventListener("change", () => {
setNodesManuallySelected([]);
const sliderValue = +sliderEl.value;
const availableNodes = sliderStack.length;
const nodesToRemove = Math.min(sliderValue, availableNodes);
nodeIdsRemovedBySlider = sliderStack.slice(
availableNodes - nodesToRemove,
availableNodes
);
// Update filter state of nodes on the stack
sliderStack.forEach((id) => {
allNodeIdsLookup[id].filterState = !nodeIdsRemovedBySlider.includes(id);
});
doFiltering("slider");
});
resetAllEl.addEventListener("click", async () => {
// Clear all filters and reset the UI
setNodesManuallySelected([]);
manuallyRemovedStack = [];
nodeIdsRemovedBySlider = [];
setButtonAvailability(restoreItemsButtonEl, false);
sliderEl.value = 0;
sliderValueEl.innerText = 0;
familyCheckBoxesEls.forEach((checkbox) => {
checkbox.checked = true;
});
// Update state of all nodes and filter the chart
setNodeState(allNodeIds, "filterState", true);
setNodeState(allNodeIds, "familyCheckbox", true);
await doFiltering();
visibleChart.layout("organic", { mode: "adaptive" });
});
}
// Set the lookup for the ranking and availability of each node for the slider
function setNodesIdsLookup(sizedNodes) {
// All node ids sorted by degree score
allNodeIds = sizedNodes
.sort((nodeA, nodeB) => nodeB.e - nodeA.e)
.map((node) => node.id);
allNodeIds.forEach((id, index) => {
allNodeIdsLookup[id] = {
// Ranking of node by size
index,
// Availability of node to be filtered by the slider
sliderAvailability: true,
// Check for whether node should be filtered from the chart
filterState: true,
// If a family checkbox is unchecked, this state ensures nodes won't be expanded
// back in if they have already been removed by the other filters
familyCheckbox: true,
};
});
// Add family property to the lookup
savedChartItems.forEach((item) => {
if (item.type === "node") {
allNodeIdsLookup[item.id].family = item.d.family;
}
});
}
// Helper function to normalize degrees score
function getResizeArray(values) {
const valuesArray = Object.values(values);
const max = Math.max.apply(null, valuesArray);
const min = Math.min.apply(null, valuesArray);
const resizeArray = Object.keys(values).map((id) => ({
id,
e: ((values[id] - min) / (max - min)) * 2 + 1,
}));
return resizeArray;
}
async function setNodesSize() {
const degreeScores = await visibleChart.graph().degrees({ value: "total" });
const resizeValues = getResizeArray(degreeScores);
visibleChart.setProperties(resizeValues);
// Save the chart to retrieve nodes sizes when filtering them back in.
savedChartItems = visibleChart.serialize().items;
return resizeValues;
}
async function startKeyLines() {
const options = {
logo: {
u: "/images/Logo.png",
},
selectedNode: {
ha0: {
c: "#5d81f8",
r: 35,
w: 15,
},
},
selectedLink: {},
};
[visibleChart, hiddenChart] = await KeyLines.create([
{ container: "klchart", options },
{ container: "hiddenChart" },
]);
visibleChart.load(data);
hiddenChart.load(data);
// Set the size of each node by normalized degree score
const nodesSizedByDegree = await setNodesSize();
// Set the order of nodes ids to be filtered for the slider
setNodesIdsLookup(nodesSizedByDegree);
// Set nodes to be filtered by the slider
setIdsForSliderStack();
visibleChart.layout("organic", { consistent: true, packing: "adaptive" });
initialiseInteractions();
setLargestComponentSize();
}
window.addEventListener("DOMContentLoaded", startKeyLines);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
<head>
<meta charset="UTF-8">
<title>Mafia Network</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="/mafia.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="/mafia.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%;">Mafia Network</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<p>Identify the important individuals in interactions among mafia families and associates and disrupt the network by removing these individuals.</p>
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div class="cicontent">
<p>Known family leaders are marked with a yellow halo.</p>
<fieldset>
<legend>Select family</legend>
<dl class="half-span">
<label class="checkbox">
<input id="batanesi" type="checkbox" name="family" checked="true">
<div class="batanesi">
<dt> </dt>
<dd>Batanesi</dd>
</div>
</label>
<label class="checkbox">
<input id="mistretta" type="checkbox" name="family" checked="true">
<div class="mistretta">
<dt> </dt>
<dd>Mistretta</dd>
</div>
</label>
</dl>
<dl class="half-span">
<label class="checkbox">
<input id="none" type="checkbox" name="family" checked="true">
<div class="none">
<dt> </dt>
<dd>Unaffiliated</dd>
</div>
</label>
</dl>
</fieldset>
<fieldset>
<legend>Remove nodes manually
<input class="btn btn-block" type="button" value="Remove selected nodes" disabled id="remove">
<input class="btn btn-block" type="button" value="Undo last remove" disabled id="restore">
</legend>
</fieldset>
<fieldset>
<legend>Remove <strong class="lead" id="sliderValue">0 </strong><strong class="lead"> largest </strong><strong class="lead">nodes</strong></legend>
<p>(based on degrees score)
<div class="sliderGroup">
<div class="sliderContainer">
<input id="slider" type="range" min="0" max="20" step="1" value="0">
</div>
</div>
</p>
</fieldset>
<div class="lead">Largest sub-network size: <strong class="lead" id="lcc"></strong></div>
<input class="span2 btn btn-kl" type="button" value="Reset network" id="resetAll">
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
<div style="display: none;" id="hiddenChart"></div>
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
.sliderContainer {
width: 100%;
}
dl {
margin: 0px;
}
dl dt {
color:#fff;
float:left;
font-weight:bold;
margin-left:0px;
margin-right:10px;
margin-block-start: 0em;
padding:3px;
width:22px;
height:22px;
border-radius: 22px;
line-height: 12px;
}
dl dd {
margin:2px 0;
padding:5px 0;
line-height: 12px;
font-size: 12px;
}
.batanesi dt {
background-color: #8e9542;
}
.mistretta dt {
background-color: #a04b6d;
}
.none dt {
background-color: white;
}
Loading source
