//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//! Investigate social structures and relations in complex email data.
import KeyLines from "keylines";
import { data, colours } from "./enron-data.js";
let chart;
let miniChart;
let restoreIds = [];
// Track UI state
const state = {
sizeBy: "same",
volume: "off",
direction: "any",
};
const colourMap = {};
/**
* debounce - This function delays execution of the passed "fn" until "timeToWait" milliseconds
* have passed since the last time it was called. This ensures that the function
* runs at the end of a particular action to keep performance high.
*/
function debounce(fn, timeToWait = 100) {
let timeoutId;
return function debouncedFn(...args) {
const timeoutFn = () => {
timeoutId = undefined;
fn.apply(this, args);
};
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(timeoutFn, timeToWait);
};
}
// ensure that this doesn't get called too often when dragging the selection
// marquee
const loadMiniChart = debounce((items) => {
miniChart.load({
type: "LinkChart",
items,
});
miniChart.layout("organic", { consistent: true });
});
function isLink(id) {
return id.match("-");
}
function updateHighlight(itemIds) {
const props = [];
// Remove previous styles
if (restoreIds) {
props.push(
...restoreIds.map((id) => {
const colour =
colourMap[id] || (isLink(id) ? colours.link : colours.node);
return {
id,
c: colour,
b: colour,
ha0: null,
};
})
);
restoreIds = [];
}
// Add new styles
if (itemIds.length) {
// Find the neighbours of the provided items
const toHighlight = [];
itemIds.forEach((id) => {
if (isLink(id)) {
const link = chart.getItem(id);
toHighlight.push(id, link.id1, link.id2);
} else {
const neighbours = chart.graph().neighbours(id);
toHighlight.push(id, ...neighbours.nodes, ...neighbours.links);
}
});
// For each neighbouring item, add some styling
toHighlight.forEach((id) => {
// Cache the existing styles
restoreIds.push(id);
// Generate new styles
const style = {
id,
};
if (isLink(id)) {
// For links, just set the colour
style.c = colours.selected;
} else {
// For nodes, add a halo
style.ha0 = {
c: colours.selected,
r: 34,
w: 6,
};
}
props.push(style);
});
}
chart.setProperties(props);
}
async function runLayout(inconsistent, mode) {
const packing = mode === "adaptive" ? "adaptive" : "circle";
return chart.layout("organic", {
time: 500,
tightness: 4,
consistent: !inconsistent,
packing,
mode,
});
}
function klReady(charts) {
[chart, miniChart] = charts;
chart.load(data);
chart.zoom("fit", { animate: false }).then(runLayout);
// On selection change:
// 1) add styles to the targeted and neighbouring items
// 2) copy the selected item and its neighbours to the miniChart
chart.on("selection-change", () => {
const ids = chart.selection();
updateHighlight(ids);
const miniChartItems = [];
if (ids.length > 0) {
const { nodes, links } = chart.graph().neighbours(ids);
chart.each({ type: "node" }, (node) => {
if (ids.includes(node.id) || nodes.includes(node.id)) {
// Clear hover styling and position
node.x = 0;
node.y = 0;
delete node.ha0;
miniChartItems.push(node);
}
});
chart.each({ type: "link" }, (link) => {
if (ids.includes(link.id) || links.includes(link.id)) {
link.c = colours.link;
miniChartItems.push(link);
}
});
}
loadMiniChart(miniChartItems);
});
}
// this function picks a colour from a range of colours based on the value
function colourPicker(value) {
const { bands, node } = colours;
if (value > 0.75) {
return bands[2];
}
if (value > 0.5) {
return bands[1];
}
if (value > 0.25) {
return bands[0];
}
return node;
}
function normalize(max, min, value) {
if (max === min) {
return min;
}
return (value - min) / (max - min);
}
function miniChartFilter(items) {
return items.filter(({ id }) => miniChart.getItem(id));
}
async function animateValues(values, links) {
const valuesArray = Object.values(values);
const max = Math.max(...valuesArray);
const min = Math.min(...valuesArray);
const items = Object.entries(values)
.map(([id, value]) => {
// Normalize the value in the range 0 -> 1
const normalized = normalize(max, min, value);
// enlarge nodes with higher values
const e = Math.max(1, normalized * 5);
// Choose a colour (use bands if there is a range of values)
const c = max !== min ? colourPicker(normalized) : colours.node;
colourMap[id] = c;
return { id, e, c, b: c };
})
.concat(links);
const miniItems = miniChartFilter(items);
// Update the main chart and miniChart concurrently
return Promise.all([
chart
.animateProperties(items, { time: 500 })
.then(() => runLayout(undefined, "adaptive")),
miniChart
.animateProperties(miniItems, { time: 500 })
.then(() => miniChart.layout("organic", { consistent: true })),
]);
}
function same() {
return new Promise((resolve) => {
const sizes = {};
chart.each({ type: "node" }, (node) => {
sizes[node.id] = 0;
});
resolve(sizes);
});
}
function wrapCallback(fn) {
return (options) => new Promise((resolve) => resolve(fn(options)));
}
function getAnalysisFunction(name) {
if (name.match(/^(degrees|pageRank|eigenCentrality)$/)) {
return wrapCallback(chart.graph()[name]);
}
if (name.match(/^(closeness|betweenness)$/)) {
return chart.graph()[name];
}
return same;
}
async function analyseChart() {
const { sizeBy, volume } = state;
const options = {};
// Configure weighting
if (volume === "on") {
if (sizeBy.match(/^(betweenness|closeness)$/)) {
options.weights = true;
}
options.value = "count";
}
// Configure direction options
if (sizeBy.match(/^(betweenness|pageRank)$/)) {
options.directed = state.direction !== "any";
} else {
options.direction = state.direction;
}
const analyse = getAnalysisFunction(sizeBy);
const values = await analyse(options);
const linkWidths = calculateLinkWidths(volume === "on");
return animateValues(values, linkWidths);
}
function calculateLinkWidths(showValue) {
const links = [];
chart.each({ type: "link" }, (link) => {
const linkcount = link.d.count;
let width = 1;
if (showValue) {
if (linkcount > 300) {
width = 36;
} else if (linkcount > 200) {
width = 27;
} else if (linkcount > 100) {
width = 18;
} else if (linkcount > 50) {
width = 9;
}
}
links.push({ id: link.id, w: width });
});
return links;
}
function doZoom(name) {
chart.zoom(name, { animate: true, time: 350 });
}
function registerClickHandler(id, fn) {
document.getElementById(id).addEventListener("click", fn);
}
function updateActiveState(nodes, activeValue) {
nodes.forEach((node) => {
if (node.value === activeValue) {
node.classList.add("active");
} else {
node.classList.remove("active");
}
});
}
function registerButtonGroup(className, handler) {
const nodes = document.querySelectorAll(`.${className}`);
nodes.forEach((node) => {
node.addEventListener("click", () => {
const { value } = node;
updateActiveState(nodes, value);
handler(value);
});
});
}
function initUI() {
// Chart overlay
registerClickHandler("home", () => {
doZoom("fit");
});
registerClickHandler("zoomIn", () => {
doZoom("in");
});
registerClickHandler("zoomOut", () => {
doZoom("out");
});
registerClickHandler("changeMode", () => {
const hand = !!chart.options().handMode; // be careful with undefined
chart.options({ handMode: !hand });
const icon = document.getElementById("iconMode");
icon.classList.toggle("fa-arrows-alt");
icon.classList.toggle("fa-edit");
});
registerClickHandler("layout", () => {
runLayout(true, "full");
});
// Right hand menu
registerButtonGroup("volume", (volume) => {
state.volume = volume;
analyseChart();
});
registerButtonGroup("size", (sizeBy) => {
state.sizeBy = sizeBy;
analyseChart();
});
registerButtonGroup("direction", (direction) => {
state.direction = direction;
analyseChart();
});
}
async function loadKeyLines() {
initUI();
const baseOpts = {
arrows: "normal",
handMode: true,
navigation: { shown: false },
overview: { icon: false },
selectedNode: {
c: colours.selected,
b: colours.selected,
fbc: colours.selected,
},
selectedLink: {
c: colours.selected,
},
};
const mainChartConfig = {
container: "klchart",
options: Object.assign({}, baseOpts, {
drag: {
links: false,
},
logo: { u: "/images/Logo.png" },
}),
};
const miniChartConfig = {
container: "minikl",
options: baseOpts,
};
const charts = await KeyLines.create([mainChartConfig, miniChartConfig]);
klReady(charts);
}
window.addEventListener("DOMContentLoaded", loadKeyLines);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
<head>
<meta charset="UTF-8">
<title>Social Network Analysis</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/font-awesome.css">
<link rel="stylesheet" type="text/css" href="/enron.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="/enron.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 class="controloverlay">
<ul>
<li><a id="home" rel="tooltip" title="Home"><i class="fa fa-home"></i></a></li>
<li><a id="layout" rel="tooltip" title="Layout chart"><i class="fa fa-random"></i></a></li>
<li><a id="changeMode" rel="tooltip" title="Drag mode"><i class="fa fa-arrows-alt" id="iconMode"></i></a></li>
<li><a id="zoomIn" rel="tooltip" title="Zoom in"><i class="fa fa-plus-square"></i></a></li>
<li><a id="zoomOut" rel="tooltip" title="Zoom out"><i class="fa fa-minus-square"></i></a></li>
</ul>
</div>
</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%;">Social Network Analysis</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<p>Click a node to see its neighbours. Select size strategy below.</p>
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div id="miniParent">
<div id="miniContainer">
<div id="minikl"></div>
</div>
</div>
<div id="analysisParent">
<div class="cicontent" id="analysisBox" style="margin-bottom:10px">
<div class="form-inline">
<div class="btn-row">
<div class="pull-left" style="width: fit-content; height: 0px;">
<p>Email Volumes:</p>
</div>
<div class="btn-group pull-right">
<button class="volume btn pull-left active" type="button" value="off" id="volumeOff">Off</button>
<button class="volume btn pull-left" type="button" value="on" id="volumeOn">On</button>
</div>
</div>
<div class="btn-row" style="margin-top:10px;">
<div class="pull-left">
<p>Size:</p>
</div>
<div class="btn-group size-btn pull-right">
<button class="size btn btn-kl pull-left active" type="button" value="same" id="same">Same</button>
<button class="size btn pull-left" type="button" value="degrees" id="degree">Degree</button>
<button class="size btn pull-left" type="button" value="closeness" id="closeness">Closeness</button>
<button class="size btn pull-left" type="button" value="betweenness" id="betweenness">Betweenness</button>
<button class="size btn pull-left" type="button" value="pageRank" id="pagerank">PageRank</button>
<button class="size btn pull-left" type="button" value="eigenCentrality" id="eigenvector">EigenCentrality</button>
</div>
</div>
<div class="btn-row" id="analysis-control">
<div class="pull-left">
<p>Analyse:</p>
</div>
<div class="btn-group pull-right">
<button class="direction btn pull-left" type="button" value="from" id="sending">Sent</button>
<button class="direction btn pull-left" type="button" value="to" id="receiving">Received</button>
<button class="direction btn btn-kl pull-left active" type="button" value="any" id="any">All</button>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
#miniContainer {
background-color: #fff;
margin: 10px;
}
#miniContainer canvas {
margin: 0 auto;
}
#minikl {
height: 232px;
}
.controloverlay {
position: absolute;
left: 12px;
top: 10px;
padding: 0;
margin: 0;
font-size: 28px;
z-index: 9001;
background-color: rgba(250, 250, 250, 0.8);
border: solid 1px #ededed;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);
}
.controloverlay ul {
list-style-type: none;
margin: 0;
padding: 6px;
}
.controloverlay li {
margin-bottom: 3px;
text-align: center;
font-size: 24px;
}
.controloverlay li:last-child {
margin-bottom: 0;
}
.controloverlay a i {
color: #2d383f;
text-decoration: none;
cursor: pointer;
}
.btn-row {
overflow: auto;
clear: both;
}
.size-btn button.btn,
.size-btn button.btn.active {
min-width: 80px;
margin-top: 4px;
margin-left: -4px;
}
.btn-group.size-btn {
width: 100%;
}
.btn-group button.size {
width: calc((100% / 3) + 1.5px);
}
#analysis-control {
margin-top:10px;
width: 100%;
}
#analysis-control .pull-left {
width: fit-content;
height: 0px;
}
#analysis-control .pull-right button {
height: 36px;
}
@media (max-width: 1023px) {
.btn-group button.size {
width: calc((100% / 2) + 2px);
}
}
@media (max-width: 767px) {
.btn-group.size-btn button.size.btn {
width: calc(100% - 60px);
margin-left: 30px;
margin-right: 30px;
}
#analysis-control { width: calc(100% + 8px); }
#analysis-control .pull-left {
height: 30px;
}
}
Loading source
