//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Investigate the source of credit card fraud.
import KeyLines from "keylines";
import {
createChartItems,
blue,
lightRed,
red,
darkRed,
lightGreen,
green,
darkGreen,
white,
black,
setItemHighlight,
getSummaryLinkGlyph,
} from "./creditcard-data.js";
let components;
let chart;
let timebar;
let aggregateCounter = 1;
let items = createChartItems();
const segmentNames = ["Disputed", "Undisputed"];
const segmentBgColors = [darkRed, darkGreen, red, green];
const segmentTextColors = [white, white, black, black];
const tooltipElements = {
tooltipEl: document.getElementById("tooltip"),
transactionsEl: document.getElementById("tooltip-transactions"),
segmentNameEl: document.getElementById("tooltip-segment-name"),
segmentValueEl: document.getElementById("tooltip-segment-value"),
valueEl: document.getElementById("tooltip-value"),
arrowEl: document.querySelector(".tooltip-arrow"),
innerEl: document.querySelector(".tooltip-inner"),
};
function runLayout() {
return chart.layout("organic", {
time: 500,
straighten: false,
easing: "linear",
mode: "adaptive",
tightness: 4,
});
}
/**
* Animates a halo around the nodes within the selected time bar 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, time: 1000, w: 30, r: 40, repeat: 1 },
);
}
}
function hideTooltip() {
tooltipElements.tooltipEl.classList.add("fadeout");
}
function showTooltip(hoverEvent) {
// tooltipX/Y is the recommended position to place a tooltip
const {
type,
value,
groupIndex,
groupValues,
tooltipX,
tooltipY,
rangeStart,
rangeEnd,
} = hoverEvent;
const toShow = type === "bar" || type === "selection";
if (toShow) {
const {
arrowEl,
innerEl,
tooltipEl,
transactionsEl,
valueEl,
segmentNameEl,
segmentValueEl,
} = tooltipElements;
// Change the tooltip content
const transactionCount = timebar.getIds(rangeStart, rangeEnd).length;
transactionsEl.innerHTML = `${transactionCount}`;
valueEl.innerHTML = `$${formatNumber(value)}`;
segmentNameEl.innerHTML = segmentNames[groupIndex % 2];
segmentValueEl.innerHTML = `$${formatNumber(groupValues[groupIndex])}`;
// Style both the tooltip body and the arrow
const backgroundColor = segmentBgColors[groupIndex];
const textColor = segmentTextColors[groupIndex];
arrowEl.style.borderTopColor = backgroundColor;
innerEl.style.backgroundColor = backgroundColor;
innerEl.style.color = textColor;
// 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();
}
}
function onSelectionChange() {
const selectedIds = chart
.selection()
.filter((id) => chart.getItem(id).type === "node");
if (selectedIds.length === 0) {
// clear the selection
chart.foreground(() => true);
timebar.selection([]);
} else {
const nodesToHighlight = getNodesToHighlight(selectedIds);
chart.foreground((node) => nodesToHighlight.has(node.id));
}
updateLinkState(selectedIds);
}
function updateLinkState(selectedIds) {
const selectedIdsSet = new Set(selectedIds);
const modifiedLinks = [];
chart.each({ type: "link" }, (link) => {
if (link.d.summaryLinkIds) return;
const selected =
selectedIdsSet.has(link.id1) || selectedIdsSet.has(link.id2);
const foregrounded = selectedIdsSet.size === 0 || selected;
link.d.status = `${link.d.disputed ? "disputed" : "undisputed"}${foregrounded ? "" : "-bg"}`;
link.d.aggregateId =
selected || link.d.userDisaggregated ? ++aggregateCounter : 0;
link.d.selected = selected;
modifiedLinks.push(link);
});
timebar.merge(modifiedLinks);
chart.merge(modifiedLinks);
}
function getNodesToHighlight(selectedIds) {
const nodesToHighlight = new Set(selectedIds);
const undisputedStoreVisitorCounts = new Map();
const selectionKinds = new Set(
selectedIds.map((nodeId) => chart.getItem(nodeId).d.label),
);
// Where only one kind of item is selected, highlight what it links to
if (selectionKinds.size === 1) {
const neighbours = chart.graph().neighbours(selectedIds);
neighbours.nodes.forEach((nodeId) => nodesToHighlight.add(nodeId));
neighbours.links.forEach((linkId) => {
const link = chart.getItem(linkId);
let ends = undisputedStoreVisitorCounts.get(link.id2);
if (ends == null) {
ends = new Set();
undisputedStoreVisitorCounts.set(link.id2, ends);
}
ends.add(link.id1);
});
// Where only people are selected, remove stores without common undisputed transactions
if (selectionKinds.has("person")) {
undisputedStoreVisitorCounts.forEach((linkedNodeIds, id) => {
if (linkedNodeIds.size !== selectedIds.length) {
nodesToHighlight.delete(id);
}
});
}
}
return nodesToHighlight;
}
async function filterChartByRange() {
await chart.filter(timebar.inRange, { type: "link", animate: false });
runLayout();
onSelectionChange();
}
function formatNumber(num) {
if (num == null) return "";
const fixed = num.toFixed(2);
return fixed.endsWith(".00") ? Math.floor(num).toString() : fixed;
}
function setAggregateLinkStyle(summary) {
const containsDispute = summary.links.some(
(id) => chart.getItem(id).d.disputed,
);
const isSelected = summary.links.some((id) => chart.getItem(id).d.selected);
const userDisaggregated = summary.links.some(
(id) => chart.getItem(id).d.userDisaggregated,
);
const totalTransactionValue = summary.links.reduce(
(total, id) => total + chart.getItem(id).d.amount,
0,
);
chart.setProperties({
id: summary.id,
c: containsDispute ? darkRed : green,
w: 4,
g: getSummaryLinkGlyph(summary, containsDispute),
padding: "3 3 0 3",
fbc: containsDispute ? darkRed : darkGreen,
fc: "white",
t: isSelected ? `$${formatNumber(totalTransactionValue)}` : undefined,
border: {
radius: 8,
},
d: {
summaryLinkIds: summary.links,
userDisaggregated: userDisaggregated,
disputed: containsDispute,
status: containsDispute ? "disputed" : "undisputed",
linkCount: summary.links.length,
},
});
}
function getLink(id) {
const item = chart.getItem(id);
return item != null && item.type === "link" ? item : null;
}
function disaggregateSummaryLink(summaryLink) {
const children = chart.getAggregateInfo(summaryLink.id).aggregateChildren;
for (let i = 0; i < children.length; ++i) {
const childId = children[i];
const item = chart.getItem(childId);
chart.setProperties({
id: children[i],
d: {
...item.d,
aggregateId: ++aggregateCounter,
userDisaggregated: true,
},
});
}
}
function initialiseEventing() {
let prevHoveredItemId = null;
chart.on("hover", ({ id, subItem }) => {
if (prevHoveredItemId !== id && prevHoveredItemId != null) {
setItemHighlight(chart, prevHoveredItemId, false);
}
if (id != null) {
setItemHighlight(chart, id, true);
prevHoveredItemId = id;
}
});
chart.on("link-aggregation", setAggregateLinkStyle);
// Remove present tooltip and filter the chart as the timebar changes
timebar.on("change", () => {
hideTooltip();
filterChartByRange();
});
let pingInterval = null;
// Display tooltip and ping relevant nodes
timebar.on("hover", (hoverEvent) => {
showTooltip(hoverEvent);
// Pings immediately on hovering
pingSelection(hoverEvent);
// Clears any existing intervals to avoid duplicate pings
clearInterval(pingInterval);
// triggers a ping every 2s while hovering
pingInterval = setInterval(() => {
pingSelection(hoverEvent);
}, 2000);
});
// When hover ends stop the pinging and reset interval
timebar.on("hoverEnd", () => {
clearInterval(pingInterval);
pingInterval = null;
});
chart.on("click", ({ preventDefault, id, subItem }) => {
const link = getLink(id);
if (link) {
const glyphClicked = subItem.type === "glyph";
const canBeDisaggregated =
link.d.summaryLinkIds?.length > 1 && !link.d.userDisaggregated;
if (glyphClicked && canBeDisaggregated) {
disaggregateSummaryLink(link);
}
// links should not be selectable
preventDefault();
}
});
chart.on("selection-change", onSelectionChange);
// Reset the chart and timebar
document.getElementById("reset").addEventListener("click", async () => {
await chart.merge(items);
await timebar.merge(items);
chart.selection([]);
onSelectionChange();
timebar.zoom("fit", {});
});
document.getElementById("recombine").addEventListener("click", async () => {
const updates = [];
chart.each({ type: "link" }, (link) => {
if (link.d.aggregateId != null) {
updates.push({
id: link.id,
d: { ...link.d, userDisaggregated: false },
});
}
});
chart.setProperties(updates);
onSelectionChange();
});
// 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);
onSelectionChange();
});
}
async function startKeyLines() {
const options = {
hover: 4,
marqueeLinkSelection: "off",
handMode: true,
selectionColour: blue,
selectedNode: {
bw: 0,
ha0: {
c: blue,
r: 20,
w: 20,
},
},
// Use this property to control the amount of alpha blending to apply to background items
backgroundAlpha: 0.25,
imageAlignment: {
// Size and centre icons
"fas fa-shopping-cart": { e: 0.7, dx: -7 },
"fas fa-user": { dy: -7, e: 0.77 },
},
iconFontFamily: "Font Awesome 5 Free",
logo: "/images/Logo.png",
aggregateLinks: { aggregateBy: "aggregateId" },
drag: {
links: false,
},
};
const tbOptions = {
showExtend: true,
playSpeed: 30,
minScale: { units: "day", value: 1 },
groups: {
groupBy: "status",
categories: ["disputed", "undisputed", "disputed-bg", "undisputed-bg"],
colours: [darkRed, green, lightRed, lightGreen],
},
};
components = await KeyLines.create([
{ container: "klchart", type: "chart", options },
{ container: "kltimebar", type: "timebar", options: tbOptions },
]);
chart = components[0];
timebar = components[1];
initialiseEventing();
await chart.load(items);
await timebar.load(items);
onSelectionChange();
await timebar.zoom("fit", {});
await runLayout();
}
async function loadKeyLines() {
await document.fonts.load('24px "Font Awesome 5 Free"');
await 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>
<tr>
<td style="text-align: right;"><strong id="tooltip-segment-name"></strong></td>
<td id="tooltip-segment-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">
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div class="cicontent">
<p>Explore the transactions to find the origin of a card skimming fraud.</p>
<p>Click <strong>Select people with disputes</strong> to view only customers with flagged transactions
and the mutual merchants they had all visited in the past.
Which shop stands out as the only one with unflagged transactions? Could that be where their cards were first cloned?
</p>
<input class="get-btn btn btn-spaced" type="button" value="Select people with disputes" id="select">
<p></p>
<p>To keep investigating, click <strong>Reset view</strong> and then select each of the flagged customers.
Use the time bar to verify that everyone's visit at Walmart took place before their first flagged transaction.
</p>
<input class="get-btn btn btn-spaced" type="button" value="Reset view" id="reset">
<p></p>
<p>
Some undisputed transactions are shown as aggregate links. Click the
<span class="fa-stack" style="color: green;font-size: 0.5em"><i class="fas fa-circle fa-stack-2x"></i><i class="fas fa-expand-alt fa-stack-1x fa-inverse"></i></span> icon to split them again.
</p>
<input class="get-btn btn btn-spaced" type="button" value="Reaggregate links" id="recombine">
</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
