//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Investigate the source of credit card fraud.
import KeyLines from "keylines";
import { data } from "./creditcard-data.js";
let components;
let chart;
let timebar;
const red = "rgba(214, 39, 40, 0.75)";
const blue = "rgb(31, 119, 180)";
const green = "rgba(44, 160, 44, 0.75)";
const orange = "rgb(255, 127, 14)";
const lightBlue = "rgb(174, 199, 232)";
const black = "rgb(0, 0, 0)";
const tooltipElements = {
tooltipEl: document.getElementById("tooltip"),
transactionsEl: document.getElementById("tooltip-transactions"),
valueEl: document.getElementById("tooltip-value"),
arrowEl: document.querySelector(".tooltip-arrow"),
innerEl: document.querySelector(".tooltip-inner"),
};
const linkAmounts = {};
function doLayout(adaptive) {
return adaptive
? chart.layout("organic", {
time: 500,
straighten: false,
easing: "linear",
mode: "adaptive",
})
: chart.layout("organic", {
time: 700,
straighten: false,
packing: "circle",
});
}
// Ping nodes that are in the selected timebar range
function pingSelection({ type, index, rangeStart, rangeEnd }) {
if (type !== "bar" && type !== "selection") {
return;
}
const timebarSelection = timebar.selection();
const selection = chart.selection();
let colour = blue;
const ids = timebar.getIds(rangeStart, rangeEnd);
let hoveredIds = [];
if (type === "selection") {
colour = index === 0 ? red : green;
hoveredIds = ids.filter((id) => timebarSelection[index].includes(id));
} else if (type === "bar") {
hoveredIds = ids;
}
// Don't ping nodes that are already selected
if (hoveredIds.length) {
const neighbours = chart.graph().neighbours(hoveredIds);
chart.ping(
neighbours.nodes.filter((node) => !selection.includes(node)),
{ c: colour }
);
}
}
function findTotalValue(index, dt1, dt2) {
const idsInRange = timebar.getIds(dt1, dt2);
let total = 0;
let ids;
if (typeof index === "number") {
// We're hovering over a selection point, so we want ids that are within the date range AND in
// the selection
ids = timebar
.selection()
[index].filter((item) => idsInRange.includes(item));
} else {
// We're hovering over a bar, not a selection point, so just need items within the date range
ids = idsInRange;
}
for (let i = 0; i < ids.length; i++) {
total += linkAmounts[ids[i]];
}
return total;
}
function hideTooltip() {
tooltipElements.tooltipEl.classList.add("fadeout");
}
function showTooltip(hoverEvent) {
// tooltipX/Y is the recommended position to place a tooltip
const {
type,
index,
value,
tooltipX,
tooltipY,
rangeStart,
rangeEnd,
} = hoverEvent;
const toShow = type === "bar" || type === "selection";
if (toShow) {
const {
arrowEl,
innerEl,
tooltipEl,
transactionsEl,
valueEl,
} = tooltipElements;
// Change the tooltip content
const totalAmount = findTotalValue(index, rangeStart, rangeEnd);
transactionsEl.innerHTML = `${value}`;
valueEl.innerHTML = `$${totalAmount}`;
// Selection colour or default colour for bar hover
let tooltipColour = blue;
if (index === 0) {
tooltipColour = red;
}
if (index === 1) {
tooltipColour = green;
}
// Style both the tooltip body and the arrow
arrowEl.style.borderTopColor = tooltipColour;
innerEl.style.backgroundColor = tooltipColour;
// The top needs to be adjusted to accommodate the height of the tooltip
const tooltipTop = tooltipY - tooltipEl.offsetHeight;
// Shift left by half width to centre it
const tooltipLeft = tooltipX - tooltipEl.offsetWidth / 2;
// Set the position of the tooltip
tooltipEl.style.left = `${tooltipLeft}px`;
tooltipEl.style.top = `${tooltipTop}px`;
// Show the tooltip
tooltipEl.classList.remove("fadeout");
} else {
hideTooltip();
}
}
// Update the glyph value for merchant nodes
function doDegrees() {
const properties = [];
const degrees = chart.graph().degrees({ value: "amount" });
chart.each({ type: "node" }, (node) => {
if (node.d.label === "merchant") {
properties.push({
id: node.id,
g: [
{
w: true,
p: "ne",
fc: black,
c: lightBlue,
b: blue,
t: `$${degrees[node.id]}`,
},
],
});
}
});
chart.setProperties(properties);
}
function doSelection() {
const chartSel = chart.selection();
if (chartSel.length) {
// We need to end up with a list of disputed link ids and a list of ok link ids
// and use those for the time bar selections.
// If links are selected they can go into the selections, but ignore when multi-select
// has an affect on the foregrounding
let i;
let j;
const neighbours = {};
const selectedNodes = [];
const neighboursCount = {};
const neighboursToInclude = [];
const disputed = [];
const ok = [];
// A selected item could be a node or a link
for (i = 0; i < chartSel.length; i++) {
const id = chartSel[i];
const item = chart.getItem(id);
if (item && item.type === "link") {
// Links go straight into the relevant selection arrays
if (item.d.status === "Disputed") {
disputed.push(id);
} else {
ok.push(id);
}
// Always include a selected link's nodes
neighboursToInclude[item.id1] = 1;
neighboursToInclude[item.id2] = 1;
} else {
selectedNodes.push(item);
// Save the selected node's neighbours
neighbours[id] = chart.graph().neighbours(id, { all: true });
}
}
for (i = 0; i < selectedNodes.length; i++) {
const node = selectedNodes[i];
// split neighbouring links into 2, disputed and ok.
const links = neighbours[node.id].links;
for (j = 0; j < links.length; j++) {
const link = chart.getItem(links[j]);
if (link.d.status === "Disputed") {
disputed.push(link.id);
} else {
ok.push(link.id);
}
}
const nodes = neighbours[node.id].nodes;
for (j = 0; j < nodes.length; j++) {
const nodeId = nodes[j];
// For neighbouring nodes, keep score of how many of the selected items have them as
// neighbours
if (!neighboursCount[nodeId]) {
neighboursCount[nodeId] = 0;
}
neighboursCount[nodeId]++;
}
}
// If a neighboursCount matches the selectedNodes length then we can include it
Object.keys(neighboursCount).forEach((countedId) => {
if (neighboursCount[countedId] === selectedNodes.length) {
neighboursToInclude[countedId] = 1;
}
});
// Foreground just the selected node and its neighbours
chart.foreground(
(node) => neighbours[node.id] || neighboursToInclude[node.id]
);
// Add the timebar selections
timebar.selection([
{ id: disputed, index: 0, c: red },
{ id: ok, index: 1, c: green },
]);
} else {
// Nothing selected, reset
chart.foreground(() => true);
timebar.selection([]);
}
// Push the label of the selected nodes down to separate them from the selection halo
chart.setProperties(
data.items
.filter((item) => item.type === "node")
.map((item) => {
item.t[0].margin = { top: chartSel.includes(item.id) ? 3 : 0 };
return {
id: item.id,
t: item.t,
};
})
);
}
async function doFilter() {
await chart.filter(timebar.inRange, { type: "link", animate: false });
doLayout(true);
doSelection();
doDegrees();
}
// Retrieve transaction amounts on links to display on tooltips
function setLinkAmounts() {
chart.each({ type: "link" }, (link) => {
linkAmounts[link.id] = link.d.amount ? link.d.amount : 0;
});
}
function initialiseInteractions() {
doSelection();
setLinkAmounts();
// Remove present tooltip and filter the chart as the timebar changes
timebar.on("change", () => {
hideTooltip();
doFilter();
});
// Display tooltip and ping relevant nodes
timebar.on("hover", (hoverEvent) => {
showTooltip(hoverEvent);
pingSelection(hoverEvent);
});
// Foreground the chart selection
chart.on("selection-change", doSelection);
// Reset the chart and timebar
document.getElementById("reset").addEventListener("click", () => {
chart.selection([]);
doSelection();
timebar.zoom("fit", {});
});
// Foreground person nodes with disputed transactions
document.getElementById("select").addEventListener("click", () => {
const toSelect = [];
chart.each({ type: "node" }, (node) => {
if (node.d.hasDisputes && node.d.label === "person") {
toSelect.push(node.id);
}
});
chart.selection(toSelect);
doSelection();
});
// Run a chart layout
document.getElementById("layout").addEventListener("click", () => {
doLayout(false);
});
}
async function startKeyLines() {
const options = {
// Use this property to control the amount of alpha blending to apply to background items
backgroundAlpha: 0.1,
iconFontFamily: "Font Awesome 5 Free",
logo: "/images/Logo.png",
selectionColour: orange,
drag: {
links: false,
},
};
const tbOptions = {
showExtend: true,
playSpeed: 30,
minScale: { units: "day", value: 1 },
groups: {
groupBy: "status",
categories: ["Disputed"],
colours: ["#ea7378"],
},
};
// Font Icons and images can often be poorly aligned,
// set offsets to the icons to ensure they are centred correctly
const imageAlignment = {
"fas fa-shopping-cart": { e: 0.75, dx: -10 },
"fas fa-user": { dy: -10 },
};
options.imageAlignment = imageAlignment;
components = await KeyLines.create([
{ container: "klchart", type: "chart", options },
{ container: "kltimebar", type: "timebar", options: tbOptions },
]);
chart = components[0];
timebar = components[1];
chart.load(data);
await timebar.load(data);
await timebar.zoom("fit", {});
await doLayout(false);
initialiseInteractions();
}
function loadKeyLines() {
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>Credit Card Fraud</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="creditcard.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="/creditcard.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 class="top" id="tooltip" style="top: 900px;">
<div class="tooltip-arrow tooltip-arrow-down"></div>
<div class="tooltip-inner">
<table class="table-condensed">
<tbody>
<tr>
<td style="text-align: right;"><strong>Transactions:</strong></td>
<td id="tooltip-transactions">{{start}}</td>
</tr>
<tr>
<td style="text-align: right;"><strong>Total Value:</strong></td>
<td id="tooltip-value">{{end}}</td>
</tr>
</tbody>
</table>
</div>
</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%;">Credit Card Fraud</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<p>Explore credit card transactions to find the origin of fraud.</p>
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div class="cicontent">
<fieldset>
<legend>Options</legend>
<input class="btn btn-block" type="button" value="Select people with disputes" id="select">
<input class="btn btn-block" type="button" value="Layout" id="layout">
<input class="btn btn-block" type="button" value="Reset" id="reset">
</fieldset>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
#tooltip .tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
.tooltip-arrow-down {
bottom: 0;
left: 50%;
margin-left: -5px;
border-top-color: #000;
border-width: 5px 5px 0;
}
#tooltip {
top: 200px;
position: absolute;
pointer-events: none;
display: block;
float: left;
color: white;
font-size: 12px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
transition: opacity 0.3s ease;
}
#tooltip tr {
height: 20px;
}
#tooltip.fadeout {
opacity: 0;
}
Loading source
