//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//! Explore relations between cyber attackers and their targets.
import KeyLines from "keylines";
import { data } from "./databreaches-data.js";
let chart;
let timebar;
let suppressedChecks = [];
const linkColours = [
"rgba(255, 0, 13, 0.7)",
"rgba(252, 132, 39, 0.7)",
"rgba(255, 207, 9, 0.7)",
"rgba(33, 252, 13, 0.7)",
"rgba(0, 253, 255, 0.7)",
"rgba(229, 153, 255, 0.7)",
"rgba(227, 19, 254, 0.7)",
"rgba(186, 153, 15, 0.7)",
];
const orange = "rgb(255, 127, 14)";
const typeToCategories = {
advanced: {
"Web application": 1,
"Remote access": 1,
"Backdoor or C2": 1,
"Command shell": 1,
VPN: 1,
"Web drive-by": 1,
},
basic: {
"LAN access": 1,
"Desktop sharing": 1,
Phone: 1,
Documents: 1,
"Direct install": 1,
"3rd party desktop": 1,
"Software update": 1,
},
careless: {
Carelessness: 1,
"Inadequate processes": 1,
"Inadequate technology": 1,
"Non-corporate": 1,
},
vArea: {
"Victim work area": 1,
"Victim public area": 1,
"Victim grounds": 1,
"Victim secure area": 1,
},
tArea: { "Public facility": 1, "Partner facility": 1, "Public vehicle": 1 },
email: {
Email: 1,
"Email attachment": 1,
"Email autoexecute": 1,
"Email link": 1,
},
physical: {
"Physical access": 1,
"Personal residence": 1,
"Personal vehicle": 1,
"In-person": 1,
},
unknown: { Unknown: 1, Other: 1, "Random error": 1 },
};
// later we'll reverse the dictionary above to quickly filter items
const categoryToType = {};
function linkColoursByNode(nodes, links) {
const linksByNode = {};
nodes.forEach((node) => {
linksByNode[node.id] = [];
});
links.forEach((link) => {
if (link && link.c) {
const linkColour = link.c;
if (linksByNode[link.id1]) {
linksByNode[link.id1].push(linkColour);
}
if (linksByNode[link.id2]) {
linksByNode[link.id2].push(linkColour);
}
}
});
return linksByNode;
}
/**
* Given an async function (fn), this function returns
* a new function that will queue up to 1 call to fn when
* invoked concurrently.
*/
function asyncThrottle(fn) {
// 0 = ready, 1 = running, 2 = queued
let state = 0;
const run = async () => {
if (state > 0) {
state = 2;
} else {
state = 1;
await fn();
const queued = state > 1;
state = 0;
if (queued) run();
}
};
return run;
}
function doLayout(mode) {
return chart.layout("organic", {
time: 300,
easing: "linear",
mode,
tightness: 8,
});
}
function getDonutsForActorNodes(nodes, linksByNode) {
return nodes.map((node) => {
const donutValues = [0, 0, 0, 0, 0, 0, 0, 0];
const linkColourList = linksByNode[node.id];
if (linkColourList) {
linkColourList.forEach((linkColour) => {
const index = linkColours.indexOf(linkColour);
if (index !== -1) {
donutValues[index]++;
}
});
}
return { id: node.id, donut: { v: donutValues } };
});
}
function getActorNodeIdsInRange(linkIdsInRange) {
const allNodeIds = [];
linkIdsInRange.forEach((id) => {
const link = chart.getItem(id);
if (link && !allNodeIds.includes(link.id1)) allNodeIds.push(link.id1);
if (link && !allNodeIds.includes(link.id2)) allNodeIds.push(link.id2);
});
return allNodeIds.filter((id) => {
const node = chart.getItem(id);
return node.d.type === "actor";
});
}
function updateDonuts() {
const range = timebar.range();
// find the attacks that occured within the timebar's range
const linkIdsInRange = timebar.getIds(range.dt1, range.dt2);
const linksInRange = chart.getItem(linkIdsInRange);
// find the actor nodes that are adjacent to those links
const actorNodeIdsInRange = getActorNodeIdsInRange(linkIdsInRange);
const actorNodesInRange = chart.getItem(actorNodeIdsInRange);
// get an object listing all the link colours for each of those actor nodes
const actorLinkColours = linkColoursByNode(actorNodesInRange, linksInRange);
// use the lists of link colours to make donuts for those actor nodes
const donutsToUpdate = getDonutsForActorNodes(
actorNodesInRange,
actorLinkColours
);
chart.animateProperties(donutsToUpdate, { time: 300, easing: "cubic" });
}
function resetTimebarSelection() {
timebar.selection([]);
}
function itemsAndNeighbours(ids) {
const result = {};
const items = chart.getItem(ids);
const neighbourIds = chart.graph().neighbours(ids, { all: true });
neighbourIds.links.concat(neighbourIds.nodes).forEach((neighbourId) => {
result[neighbourId] = true;
});
items.forEach((item) => {
result[item.id] = true;
});
return result;
}
function neighbouringCriterion(ids) {
const idsToForeground = itemsAndNeighbours(ids);
return (item) => idsToForeground[item.id];
}
// foreground/background the chart items based on whether they neighbour a checked attack vector
function foregroundCheckedAttackVectors() {
// make all checkboxes determinate
document.querySelectorAll(".vector input").forEach((checkbox) => {
checkbox.indeterminate = false;
});
// re-check suppressed checkboxes
suppressedChecks.forEach((checkbox) => {
checkbox.checked = true;
});
suppressedChecks = [];
const threshold = 3;
// first read the number of checkboxes checked
const checked = document.querySelectorAll(".vector input:checked");
// if there are more than 3 uncheck this last one and return
if (checked.length > threshold) {
// just exit
this.checked = false;
return;
}
document
.querySelectorAll(".vector input:not(:checked)")
.forEach((checkbox) => {
checkbox.disabled = checked.length === threshold;
});
resetTimebarSelection();
const criteria = [];
if (checked.length) {
const checkedTypes = {};
checked.forEach((el, i) => {
// save type -> selection object in this dictionary
checkedTypes[el.id] = {
id: [],
index: i,
c: el.parentElement.querySelector("span.color-legend").style
.backgroundColor,
};
});
chart.each({ type: "link" }, (link) => {
const type = categoryToType[link.d.type];
if (type in checkedTypes) {
checkedTypes[type].id.push(link.id);
}
});
const selectionList = [];
Object.keys(checkedTypes).forEach((index) => {
const ids = checkedTypes[index].id;
criteria.push(neighbouringCriterion(ids));
selectionList.push(checkedTypes[index]);
});
timebar.selection(selectionList);
// foreground the checked vectors
chart.foreground((item) => criteria.some((criterion) => criterion(item)));
} else {
// no vector checkboxes are checked, so foreground everything
chart.foreground(() => true);
}
}
function forEachVictimNode(fn) {
chart.each({ type: "node" }, (node) => {
if (node.d.type === "victim") {
fn(node);
}
});
}
async function resetVector(e) {
// reset the checkboxes
document.querySelectorAll(".vector input").forEach((input) => {
input.checked = false;
input.disabled = false;
input.indeterminate = false;
});
// clear the chart selection
chart.selection([]);
resetTimebarSelection();
chart.foreground(() => true);
// reset the size of companies as well
const changes = [];
forEachVictimNode((node) => {
changes.push({ id: node.id, e: 1 });
});
await chart.animateProperties(changes, {});
doLayout("adaptive");
e.preventDefault();
}
function normalize(value, min, max) {
if (min === max) {
return value;
}
return (value - min) / (max - min);
}
async function sizeByCompanyVectors() {
const degrees = chart.graph().degrees();
const changes = [];
let max = -Infinity;
let min = Infinity;
// first pass: find the max and min degrees
forEachVictimNode((node) => {
if (node.id in degrees) {
max = Math.max(max, degrees[node.id]);
min = Math.min(min, degrees[node.id]);
}
});
// second pass, now size the nodes
forEachVictimNode((node) => {
if (node.id in degrees) {
changes.push({
id: node.id,
e: 1 + 6 * normalize(degrees[node.id], min, max),
});
}
});
await chart.animateProperties(changes, { time: 800 });
doLayout("adaptive");
}
const filterOnTimebarChange = asyncThrottle(async () => {
// filter the chart to show only items in the new range
await chart.filter(timebar.inRange, { animate: false, type: "link" });
updateDonuts();
await doLayout("adaptive");
});
function foregroundOnSelectionChange() {
resetTimebarSelection();
const selection = chart.selection();
if (selection.length) {
// foreground the selected items, and any neighbours thereof
const foreground = itemsAndNeighbours(selection);
// find all the attack types that have a link in the foreground
const selectedAttackTypes = [];
chart.foreground(
(link) => {
if (foreground[link.id]) {
const type = categoryToType[link.d.type];
selectedAttackTypes.push(type);
return true;
}
return false;
},
{ type: "link" }
);
document.querySelectorAll(".vector input").forEach((checkbox) => {
// for the selected attack types, make the corresponding checkboxes indeterminate
checkbox.indeterminate = selectedAttackTypes.includes(checkbox.id);
// uncheck any other checked checkboxes
if (checkbox.checked && !checkbox.indeterminate) {
checkbox.checked = false;
// record that we unchecked this checkbox, so we can re-check it later
suppressedChecks.push(checkbox);
}
});
} else {
// In this case, the click was on the chart background,
// so we do the foregrounding in accordance with checkbox state.
foregroundCheckedAttackVectors();
}
}
async function klReady(components) {
[chart, timebar] = components;
chart.load(data);
chart.zoom("fit");
timebar.load(data);
await timebar.zoom("fit", { time: 100 });
// Setup Filters
// when the time bar range changes, filter the chart accordingly
timebar.on("change", filterOnTimebarChange);
// handle clicks by foregrounding the selected item(s) and neighbours thereof
chart.on("selection-change", foregroundOnSelectionChange);
// reverse the typeToCategory dictionary
Object.keys(typeToCategories).forEach((type) => {
const categories = typeToCategories[type];
Object.keys(categories).forEach((category) => {
categoryToType[category] = type;
});
});
// Vector Filter
document.querySelectorAll(".vector input").forEach((input) => {
input.addEventListener("change", foregroundCheckedAttackVectors, false);
input.addEventListener("keyup", foregroundCheckedAttackVectors, false);
});
document
.getElementById("reset")
.addEventListener("click", resetVector, false);
// add an explanation to the vector categories
document.querySelectorAll(".vector").forEach((el) => {
const categories =
typeToCategories[el.querySelector("input").getAttribute("id")];
const names = Object.keys(categories);
const label = el.querySelector("span.text-legend");
const wrapperSpan = el.querySelector("span.popover-wrapper");
wrapperSpan.setAttribute("data-title", label.textContent);
wrapperSpan.setAttribute("data-content", names.join(", "));
});
// Layout button
document.getElementById("layout").addEventListener("click", doLayout, false);
document
.getElementById("degrees")
.addEventListener("click", sizeByCompanyVectors, false);
}
async function startKeyLines() {
const attackerIcon = "fas fa-users";
const chartOptions = {
controlTheme: "dark",
drag: {
links: false,
},
handMode: true,
iconFontFamily: "Font Awesome 5 Free",
overview: { icon: false, shown: false },
minZoom: 0.01,
selectionColour: orange,
linkEnds: { avoidLabels: false },
imageAlignment: {},
backColour: "#2d383f",
};
chartOptions.imageAlignment[attackerIcon] = {
e: 0.8,
};
const timeBarOptions = {
area: { colour: "#FFFFFF" },
backColour: "#2d383f",
controlBarTheme: "dark",
scale: { highlightColour: "#475259" },
playSpeed: 50,
sliders: "none",
type: "area",
};
const components = await KeyLines.create([
{
container: "klchart",
type: "chart",
options: chartOptions,
},
{
container: "kltimebar",
type: "timebar",
options: timeBarOptions,
},
]);
klReady(components);
}
function loadFontsAndStart() {
document.fonts.load('24px "Font Awesome 5 Free"').then(startKeyLines);
}
window.addEventListener("DOMContentLoaded", loadFontsAndStart);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
<head>
<meta charset="UTF-8">
<title>Filter Data Breaches</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">
<link rel="stylesheet" type="text/css" href="databreaches.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="/vendor/bootstrap.js" defer type="text/javascript"></script>
<script src="/databreaches.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 klchart-timebar" id="klchart">
</div>
<div class="kltimebar" id="kltimebar">
</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%;">Filter Data Breaches</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">
<fieldset>
<legend>Attack vectors legend:</legend>
<p>Select up to three attack vectors to compare.</p>
<ul class="legend" style="margin-left: 4px">
<li>
<label class="checkbox vector">
<input type="checkbox" id="advanced" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(255, 0, 13)"> </span><span class="text-legend">Advanced tech</span></span>
</label>
</li>
<li>
<label class="checkbox vector">
<input type="checkbox" id="basic" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(252, 132, 39)"> </span><span class="text-legend">Basic Tech</span></span>
</label>
</li>
<li>
<label class="checkbox vector">
<input type="checkbox" id="careless" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(255, 207, 9)"> </span><span class="text-legend">Carelessness</span></span>
</label>
</li>
<li>
<label class="checkbox vector">
<input type="checkbox" id="vArea" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(33, 252, 13)"> </span><span class="text-legend">Victim area</span></span>
</label>
</li>
<li>
<label class="checkbox vector">
<input type="checkbox" id="tArea" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(0, 253, 255)"> </span><span class="text-legend">Third party facility</span></span>
</label>
</li>
<li>
<label class="checkbox vector">
<input type="checkbox" id="email" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(229, 153, 255)"> </span><span class="text-legend">Email</span></span>
</label>
</li>
<li>
<label class="checkbox vector">
<input type="checkbox" id="physical" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(227, 19, 254)"> </span><span class="text-legend">Physical access</span></span>
</label>
</li>
<li>
<label class="checkbox vector">
<input type="checkbox" id="unknown" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(186, 153, 15)"> </span><span class="text-legend">Unknown</span></span>
</label>
</li>
</ul>
<input class="btn btn-spaced" type="button" value="Clear" id="reset">
<input class="btn btn-spaced" type="button" value="Layout" id="layout">
<input class="btn btn-spaced" type="button" value="Size Companies" id="degrees">
</fieldset>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
ul.legend {
margin-top: 14px;
margin-bottom: 15px;
list-style: none;
padding: 0px;
}
ul.legend li {
height: 30px;
}
ul.legend input {
margin-top: 9px;
}
ul.legend span.color-legend {
color: #fff;
display: inline-block;
font-weight: bold;
margin-right: 5px;
margin-left: 0px;
padding: 4px;
width: 28px;
height: 28px;
border-radius: 28px;
}
.highlight {
font-weight: bold;
}
.color-legend {
margin-left: 5px;
}
#victimName {
font-size: 14px;
}
.typeahead {
max-width: 161px;
min-width: 161px;
border: 1px solid #ccc
}
.typeahead li {
font-size: 14px;
background-color: transparent;
padding: 2px 5px;
width: 100%;
}
.typeahead li a {
color: #009968;
width: 100%;
}
.typeahead li:hover a {
color: #fff;
}
.typeahead li.active a {
color: #fff;
}
.typeahead li.active {
background-color: #009968;
}
.popover {
display: none;
height: 80px;
width: 250px;
font-size: 16px;
line-height: 16px;
margin: 0px;
border: 1px solid #ccc;
z-index: 1000;
margin-bottom: -80px;
}
.highlight {
font-weight:bold;
}
.klchart, #fullscreen.fullscreenrow .cichart, #fullscreen.fullscreenrow .klchart {
border: none;
background-color: #2d383f;
}
.kltimebar {
border: none;
border-top: dashed 1px grey;
background-color: #2d383f;
}
.popover .popover-title {
padding: 4px;
margin: 0px;
font-size: 16px;
line-height: 16px;
width: 100%
}
.popover .popover-content {
padding: 4px;
margin: 0px;
font-size: 14px;
line-height: 14px;
background-color: #fff;
height: 54px;
width: 100%;
}
.arrow {
background-color: #fff;
border-top: 1px solid #ccc;
border-right: 1px solid #ccc;
transform: translateX(244px) translateY(35px) rotateZ(45deg);
width: 10px;
height: 10px;
position: absolute;
}
Loading source
