//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Find matches and flag fraudulent insurance claims.
import KeyLines from "keylines";
import data from "./insurance-data.js";
let chart;
let comboIds = [];
let matchedGroupsOfIds = [];
const defaultStyling = {
iconColours: {
address: "#A42768",
person: "#A674BA",
accident: "#7D7D7D",
vehicle: "#7FCB68",
doctor: "#1F78B4",
},
claimColour: "#E34A0E",
nodeColour: "#FFFFFF",
typeIcons: {
person: "fas fa-user",
address: "fas fa-home",
accident: "fas fa-file-invoice-dollar",
vehicle: "fas fa-car",
doctor: "fas fa-user-md",
},
linkLabel: {
padding: "3 6 0 6",
border: {
radius: "6 0 6 0",
width: 2,
colour: "grey",
},
},
};
const layoutOptions = {
tightness: 0,
stretch: 0.6,
};
let menuGlyphIsHighlighted = false;
const highlightedMenuGlyph = {
fi: {
t: "fas fa-ellipsis-v",
c: "#E30000",
},
p: 55,
r: 43,
c: "rgba(0,0,0,0)",
b: "rgba(0,0,0,0)",
e: 3.25,
};
const menuGlyph = {
fi: {
t: "fas fa-ellipsis-v",
c: defaultStyling.claimColour,
},
p: 55,
r: 43,
c: "rgba(0,0,0,0)",
b: "rgba(0,0,0,0)",
e: 2.75,
};
const claimList = document.getElementById("claimList");
const findMatchesButton = document.getElementById("findMatchesButton");
const combineButton = document.getElementById("combineButton");
const uncombineButton = document.getElementById("uncombineButton");
const contextMenu = document.getElementById("contextMenu");
const acceptClaimButton = document.getElementById("accept");
const referClaimButton = document.getElementById("refer");
const claimStatusText = document.getElementById("claimStatusText");
function getNodeInfoByType(node) {
switch (node.d.type) {
case "accident":
return node.d.date;
case "person":
return `${node.d.lastName}, ${node.d.firstName}`;
case "doctor":
return `Doctor\n${node.d.lastName}, ${node.d.firstName}`;
case "address":
return node.d.address.split(", ").join("\n");
case "vehicle":
return node.d.registration;
default:
return null;
}
}
function getVisibleClaims() {
const visibleClaims = [];
chart.each({ type: "node" }, (node) => {
if (!node.hi && node.d.type === "accident") {
visibleClaims.push(node.id);
}
});
return visibleClaims;
}
function getIdsConnectedToClaim(claimId) {
const distances = chart.graph().distances(claimId, { all: true });
return Object.keys(distances);
}
function matchData(searchTerms, nodeSearchDict) {
const matchedIdsArrays = [];
searchTerms.forEach((searchItem) => {
const matches = nodeSearchDict[searchItem.type][searchItem.searchString];
if (matches) {
const matchingIdsGroup = [searchItem.id, ...matches];
matchedIdsArrays.push(matchingIdsGroup);
}
});
return matchedIdsArrays;
}
async function findMatches() {
findMatchesButton.disabled = true;
// Make dictionary of the hidden node indexed by type and searchString
// and another dictionary of items in claim tree which we will use to search
const nodeSearchDict = {};
const searchTerms = [];
chart.each({ type: "node" }, (node) => {
const searchString = getNodeInfoByType(node);
if (node.hi) {
nodeSearchDict[node.d.type] = nodeSearchDict[node.d.type] || {};
nodeSearchDict[node.d.type][searchString] =
nodeSearchDict[node.d.type][searchString] || [];
nodeSearchDict[node.d.type][searchString].push(node.id);
} else {
searchTerms.push({
type: node.d.type,
searchString,
id: node.id,
});
}
});
// Makes an array of grouped ids
matchedGroupsOfIds = matchData(searchTerms, nodeSearchDict);
// for each similar node gets rest of the claim tree
const showIdsList = [];
matchedGroupsOfIds.forEach((matchedIdsGroup) => {
matchedIdsGroup.forEach((id) => {
showIdsList.push(...getIdsConnectedToClaim(id));
});
});
await chart.show(showIdsList, true);
const visibleClaims = getVisibleClaims();
await chart.layout("sequential", { top: visibleClaims, ...layoutOptions });
combineButton.disabled = false;
}
async function uncombineMatches(animate) {
if (comboIds.length) {
await chart.combo().uncombine(comboIds, { animate, select: false });
}
comboIds = [];
}
async function closeClaim() {
await uncombineMatches(false);
matchedGroupsOfIds = [];
await chart.filter(() => false, { type: "node", animate: false });
}
function setClaimStatus(status) {
claimStatusText.innerText = status;
if (status === " Accepted") {
claimStatusText.style.color = "green";
} else if (status === " Referred") {
claimStatusText.style.color = "red";
} else {
claimStatusText.style.color = "gray";
}
}
async function showClaim(claimId) {
const idsToShow = getIdsConnectedToClaim(claimId);
await chart.show(idsToShow, true);
}
function setLinkProperties() {
const linkProperties = [];
chart.each({ type: "link" }, (link) => {
if (!link.hi) {
linkProperties.push({
id: link.id,
c: defaultStyling.claimColour,
w: 3,
border: {
...defaultStyling.linkLabel.border,
colour: defaultStyling.claimColour,
},
});
}
});
chart.setProperties(linkProperties, false);
}
async function resetChart() {
claimList.disabled = true;
findMatchesButton.disabled = true;
combineButton.disabled = true;
uncombineButton.disabled = true;
await closeClaim();
setClaimStatus(" Undecided");
const claimId = claimList.value;
await showClaim(claimId);
setLinkProperties();
await chart.layout("sequential", { top: [claimId], ...layoutOptions });
findMatchesButton.disabled = false;
claimList.disabled = false;
}
function showContextMenu(x, y) {
contextMenu.style.display = "block";
const { width, height } = chart.viewOptions();
const { width: w, height: h } = contextMenu.getBoundingClientRect();
const top = y + h < height ? y : y - h;
const left = x + w < width ? x : x - w;
contextMenu.style.top = `${top}px`;
contextMenu.style.left = `${left}px`;
}
function hideContextMenu() {
contextMenu.style.display = "none";
}
function isMenuGlyph({ type }) {
return type === "glyph";
}
function menuGlyphContextMenu({ x, y, preventDefault, subItem }) {
hideContextMenu();
// only opens context menu if glyph clicked
if (isMenuGlyph(subItem)) {
preventDefault();
showContextMenu(x, y);
}
}
function menuGlyphHoverHandler({ subItem }) {
const id = claimList[claimList.selectedIndex].value;
if (isMenuGlyph(subItem) && !menuGlyphIsHighlighted) {
chart.setProperties({
id,
g: [highlightedMenuGlyph],
});
menuGlyphIsHighlighted = true;
} else if (menuGlyphIsHighlighted) {
chart.setProperties({
id,
g: [menuGlyph],
});
menuGlyphIsHighlighted = false;
}
}
async function combineMatches() {
const matchsComboDefinitions = matchedGroupsOfIds.map((idGroup) => {
const fixedNode = chart.getItem(idGroup[0]);
return {
ids: idGroup,
label: fixedNode.t,
style: {
c: fixedNode.c,
b: fixedNode.b,
bw: 7,
fi: fixedNode.fi,
d: fixedNode.d,
},
position: "first",
};
});
comboIds = await chart
.combo()
.combine(matchsComboDefinitions, { select: false });
}
async function combineButtonHandler() {
combineButton.disabled = true;
await combineMatches();
uncombineButton.disabled = false;
}
async function uncombineButtonHandler() {
uncombineButton.disabled = true;
await uncombineMatches(true);
combineButton.disabled = false;
}
function buildEventHandlers() {
// open the contextMenu when glyph is clicked
chart.on("click", menuGlyphContextMenu);
// when user drags a node, pans, zooms hide the contextMenu
chart.on("drag-start", hideContextMenu);
chart.on("view-change", hideContextMenu);
// changes the menu glyph when hovering over to make clear its interactable
chart.on("hover", menuGlyphHoverHandler);
// hide menu when clicked
contextMenu.addEventListener("click", hideContextMenu);
findMatchesButton.addEventListener("click", findMatches);
combineButton.addEventListener("click", combineButtonHandler);
uncombineButton.addEventListener("click", uncombineButtonHandler);
acceptClaimButton.addEventListener("click", () =>
setClaimStatus(" Accepted")
);
referClaimButton.addEventListener("click", () => setClaimStatus(" Referred"));
claimList.addEventListener("change", resetChart);
}
function addStyles() {
data.items.forEach((dataItem) => {
if (dataItem.type === "node") {
dataItem.hi = true;
dataItem.c = defaultStyling.nodeColour;
dataItem.b = defaultStyling.iconColours[dataItem.d.type];
dataItem.fi = {
t: defaultStyling.typeIcons[dataItem.d.type],
c: defaultStyling.iconColours[dataItem.d.type],
};
dataItem.t = getNodeInfoByType(dataItem);
// Sets styles for claims 1 & 2
if (dataItem.id === "0108" || dataItem.id === "0109") {
dataItem.b = defaultStyling.claimColour;
dataItem.fi = {
c: defaultStyling.claimColour,
t: defaultStyling.typeIcons.accident,
};
// this adds the menu glyph on the main claim
dataItem.g = [menuGlyph];
}
} else {
dataItem.padding = defaultStyling.linkLabel.padding;
dataItem.fbc = "white";
dataItem.border = defaultStyling.linkLabel.border;
}
});
}
async function startKeyLines() {
// Font Icons and images can often be poorly aligned,
// set offsets to the icons to ensure they are centred correctly
const imageAlignment = {};
const imageAlignmentDefinitions = {
"fas fa-user": { dy: -10, e: 1.05 },
};
// List of icons to realign
const icons = Object.keys(imageAlignmentDefinitions);
icons.forEach((icon) => {
imageAlignment[icon] = imageAlignmentDefinitions[icon];
});
const options = {
// use this property to control the amount of alpha blending to apply to background items
backgroundAlpha: 0.1,
logo: "/images/Logo.png",
iconFontFamily: "Font Awesome 5 Free",
handMode: true,
imageAlignment,
hover: 0,
};
chart = await KeyLines.create({ container: "klchart", options });
buildEventHandlers();
addStyles();
chart.load(data);
resetChart();
}
function loadFonts() {
document.fonts.load('24px "Font Awesome 5 Free"').then(startKeyLines);
}
window.addEventListener("DOMContentLoaded", loadFonts);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
<head>
<meta charset="UTF-8">
<title>Insurance Claim Workflows</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="/insurance.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="/insurance.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">
<ul class="kl-dropdown-menu" id="contextMenu" role="menu">
<li><a id="accept" tabindex="-1"><i class="fas fa-fw fa-check" style="color:green;"></i>Accept claim</a><a id="refer" tabindex="-1"><i class="fas fa-fw fa-times" style="color:red;"></i>Flag for further investigation</a></li>
</ul>
</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%;">Insurance Claim Workflows</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<p>Find matches and flag fraudulent insurance claims.</p>
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div class="cicontent">
<h5>Selected Claim:</h5>
<select class="input-block-level" id="claimList" disabled>
<option value="0109" id="0109">Claim 1</option>
<option value="0108" id="0108">Claim 2</option>
</select>
<h5>Claim Status:<span style="font-weight:bold" id="claimStatusText"> Undecided</span></h5>
<p></p>
<p>The root claim node being investigated is red, as are the relationships that are part of the claim.</p>
<p>Find matches against historical data.</p>
<input class="get-btn btn btn-spaced" type="button" value="Find Matches" disabled="disabled" id="findMatchesButton">
<p></p>
<p>Combine entities to expose patterns of behaviour.</p>
<input class="get-btn btn btn-spaced" type="button" value="Combine Matches" disabled="disabled" id="combineButton">
<input class="get-btn btn btn-spaced" type="button" value="Uncombine Matches" disabled="disabled" id="uncombineButton">
<p></p>
<fieldset>
<legend>Context Menu</legend>
<p>Once your investigation is complete you can click on the red claim's <i class="fas fa-ellipsis-v"></i> menu to accept or reject the claim.</p>
</fieldset>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
.modal {
width: 200px;
position: absolute;
margin-left: -100px;
font-size: 16px;
border-radius: 0;
box-shadow: none;
}
.modal-footer {
background-color: white;
text-align: center;
border-top: none;
padding-top: 0;
}
Loading source
