//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Explore insurance data for fraudulent activity.
import KeyLines from "keylines";
import {
data,
defaultStyle,
defaultNodeStyle,
defaultLinkStyle,
mapNodeStyle,
mapLinkStyle,
comboStyle,
comboGlyphStyle,
selectedNodeStyle,
defaultLabelStyle,
} from "./insurancefraudanalysis-data.js";
// Returns colour for betweenness sizing
function getColour(value) {
if (value < 0.25) {
return "#A674BA";
}
if (value < 0.5) {
return "#844C9A";
}
if (value < 0.75) {
return "#583267";
}
return "#2C1933";
}
let chart;
let underlyingGraph;
const modelElement = document.getElementById("model");
const descriptorElements = Array.from(document.querySelectorAll(".model-text"));
let singleGarageMode = false;
/* Create chart items helpers */
// Returns the current model
function getSelectedModel() {
return { model: modelElement.value };
}
// Changes the base map layer
function mapBaseLayer() {
const leafletMap = chart.map().leafletMap();
const basemap = L.esri.basemapLayer("Topographic");
basemap.addTo(leafletMap);
}
// Returns array of all combo node ids
function getComboIds() {
const comboIds = [];
chart.each({ type: "node", items: "toplevel" }, (item) => {
comboIds.push(item.id);
});
return comboIds;
}
// Returns the single selected id or null
function getSelection() {
const selection = chart.selection();
if (selection.length === 0) {
return null;
}
return selection[0];
}
// Returns array of items excluding selected item and its neighbours
function getUnrelatedItems(nodes, selectedId) {
const unrelatedItems = [];
chart.each({ type: "node" }, (item) => {
if (!nodes.includes(item.id) && item.id !== selectedId) {
unrelatedItems.push(item.id);
}
});
return unrelatedItems;
}
// Return nodes based on their d.kind property
function getNodesByKind(kind) {
const nodes = [];
chart.each({ type: "node" }, (n) => {
if (n.d && n.d.kind === kind) {
nodes.push(n);
}
});
return nodes;
}
// Return font icon from item property
function getIconByKind(kind) {
return defaultStyle.kindIcons[kind];
}
// Returns neighbours of an item filtered by kind
function getNeighboursByKind(id, kind, hops = 1) {
const neighbourIds = chart.graph().neighbours(id, { hops }).nodes.concat(id);
const neighbours = chart.getItem(neighbourIds);
const neighboursOfKind = neighbours.filter((n) => n.d && n.d.kind === kind);
return neighboursOfKind.map((node) => node.id);
}
// Get the paths for a model
function findNetworkSelection(id) {
let selectedId = id;
// If we've selected one of the 4 nodes to do with a policy,
// then pretend we selected the claim instead
const item = chart.getItem(id);
if (item.d.kind === "person") {
const policies = getNeighboursByKind(id, "policy");
if (policies.length === 1) {
selectedId = policies[0];
}
} else if (["address", "telephone"].includes(item.d.kind)) {
selectedId = getNeighboursByKind(id, "policy", 2)[0];
}
// Find the neighbouring claims to our selection.
const claims = getNeighboursByKind(selectedId, "claim");
if (claims.length === 0) {
return chart
.graph()
.neighbours(selectedId, { hops: 2 })
.nodes.concat(selectedId);
}
const neighbourIds = [...chart.graph().neighbours(claims).nodes, ...claims];
// Fill in the policy data - next to the person who took the policy
const policies = getNeighboursByKind(claims.concat(selectedId), "policy");
const policyTakers = getNeighboursByKind(policies, "person");
const policyData = [
...chart.graph().neighbours(policyTakers).nodes,
...policyTakers,
];
return [...neighbourIds, ...policyData];
}
function styleSuspiciousItems(linkId) {
let comboId;
if (!chart.combo().isCombo(linkId)) {
comboId = chart.combo().find(linkId);
} else {
comboId = linkId;
}
const underlyingLinks = chart.combo().info(comboId).links;
// Highlight items where there is a large proportion of the same repairs
if (underlyingLinks.length > 12) {
const propLink = {
id: linkId,
w: 10,
c: defaultStyle.linkColours.suspiciousConnection,
};
const propNode = {
id: chart.getItem(linkId).id1,
fi: {
c: defaultStyle.nodeColours.suspiciousGarage,
t: getIconByKind("garage"),
},
};
return [propLink, propNode];
}
return [];
}
// Get colour of link based on distance between person and garage
function getMapLinkColour(link) {
const id1 = chart.getItem(link.id1);
const id2 = chart.getItem(link.id2);
const xDist = id1.x - id2.x;
const yDist = id1.y - id2.y;
const distanceSquared = xDist * xDist + yDist * yDist;
// Recolour longer distances
if (distanceSquared > 210 * 210) {
return defaultStyle.linkColours.suspiciousConnection;
}
return defaultStyle.linkColours.normalDistance;
}
/* END of helper functions to create chart items */
function applyMapStyling() {
const props = [];
// Styling of nodes in map mode.
chart.each({ type: "node" }, (item) => {
if (defaultStyle.nodeColours[item.d.kind]) {
props.push(
Object.assign({}, mapNodeStyle, {
id: item.id,
g: {
p: "ne",
fi: {
t: getIconByKind(item.d.kind),
c: defaultStyle.nodeColours[item.d.kind],
},
e: 2,
},
})
);
}
});
// Styling of links
chart.each({ type: "link" }, (item) => {
props.push(
Object.assign({}, mapLinkStyle, {
id: item.id,
c: getMapLinkColour(item),
})
);
});
chart.animateProperties(props, { time: 250 });
}
/* Controls the chart selection behaviour */
// Default selection foregrounds neighbours of selection
function defaultOnSelect(id) {
const item = chart.getItem(id);
if (id === null) {
// clicked on background - restore all the elements in the foreground
chart.foreground(() => true, { type: "all" });
chart.selection([]);
} else if (item && item.type === "node") {
// show only direct neighbours of nodes
const result = chart.graph().neighbours(id).nodes.concat(item.id);
chart.foreground((node) => result.includes(node.id), { type: "node" });
chart.selection([id]);
}
}
// Network selection foregrounds neighbours of nearest claim
function networkOnSelect(id) {
const item = chart.getItem(id);
if (id === null) {
// clicked on background - restore all the elements in the foreground
chart.foreground(() => true, { type: "all" });
chart.selection([]);
} else if (item && item.type === "node") {
const result = findNetworkSelection(id);
chart.foreground((node) => result.includes(node.id), { type: "node" });
chart.selection([id]);
}
}
// In 'garage-repair' view, we hide items that are not neighbours
async function garageOnSelect(id) {
const selectedModel = getSelectedModel();
if (singleGarageMode) {
// If background selected then return to full garage-damages model
if (id === null) {
const filterOptions = {
hideSingletons: true,
};
// Filters the chart to include all previously hidden items for the model
await chart.filter(
(node) => node.d.models.includes(selectedModel.model),
filterOptions
);
singleGarageMode = false;
chart.combo().reveal([]);
chart.layout("standard", { consistent: true });
}
return;
}
const selectedItem = chart.getItem(id);
if (!(selectedItem && selectedItem.d && selectedItem.d.kind === "garage")) {
return;
}
// Return underlying graph from graph engine
underlyingGraph = KeyLines.getGraphEngine();
underlyingGraph.load(chart.serialize());
// Find neighbours of selected item and hide items that are not
const garageUnderlyingNeighbours = underlyingGraph.neighbours(
selectedItem.id
);
const garageComboNeighbours = chart.graph().neighbours(selectedItem.id);
chart.combo().close(garageComboNeighbours.nodes);
await chart.hide(
getUnrelatedItems(garageUnderlyingNeighbours.nodes, selectedItem.id)
);
singleGarageMode = true;
await chart.layout("radial", { top: selectedItem.id });
chart.combo().reveal(garageUnderlyingNeighbours.links);
let props = [];
garageUnderlyingNeighbours.links.forEach((linkId) => {
const linkProps = styleSuspiciousItems(linkId);
props = props.concat(linkProps);
});
chart.setProperties([
...garageComboNeighbours.links.map((comboLinkId) => ({
id: comboLinkId,
hi: true,
})),
...props,
]);
}
/* END chart selection functions */
// Damages are combined with links to garages
async function combineDamages() {
const garages = getNodesByKind("garage");
const damages = getNodesByKind("damage");
const damagesGroup = damages.reduce((groupBy, damage) => {
groupBy[damage.d.subkind] = (groupBy[damage.d.subkind] || []).concat(
damage
);
return groupBy;
}, {});
let props = [];
const comboDefinition = Object.keys(damagesGroup).map((subkind) => ({
ids: damagesGroup[subkind].map((damage) => damage.id),
style: {
...comboStyle,
t: {
...defaultLabelStyle,
t: subkind,
},
},
open: false,
}));
const comboOptions = { arrange: "concentric", select: false };
await chart.combo().combine(comboDefinition, comboOptions);
// Highlight suspicious activity between neighbours
garages.forEach((g) => {
const linkIds = chart.graph().neighbours(g.id).links;
linkIds.forEach((linkId) => {
const linkProps = styleSuspiciousItems(linkId);
props = props.concat(linkProps);
});
// Resize the garages to scale with combos
props.push({ id: g.id, e: 10 });
});
// Apply properties and run layout
await chart.setProperties(props, false);
await chart.layout("standard", { consistent: true });
}
/* Transition functions for each model */
async function fullNetworkTransition(props, options) {
await chart.animateProperties(props, options);
chart.layout("organic", { tightness: 4, consistent: true });
}
async function peopleTransition(props, options) {
chart.foreground(() => true, { type: "all" });
await chart.animateProperties(props, options);
await chart.layout("organic", { consistent: true, packing: "circle" });
}
async function personGarageTransition(props, options) {
chart.foreground(() => true, { type: "all" });
await chart.animateProperties(props, options);
await chart.map().show();
await chart.zoom("fit");
}
async function garageDamagesTransition(props, options) {
chart.foreground(() => true, { type: "all" });
chart.selection([]);
singleGarageMode = false;
await chart.animateProperties(props, options);
combineDamages();
}
/* END Transition functions for each model */
// Define model object properties
const models = {
none: {
transition: fullNetworkTransition,
onSelect: networkOnSelect,
},
"garage-damages": {
// Return to default settings to avoid unnecessary behaviour on item selection
transition: garageDamagesTransition,
onSelect: garageOnSelect,
},
people: {
transition: peopleTransition,
onSelect: defaultOnSelect,
},
"person-garage": {
transition: personGarageTransition,
onSelect: defaultOnSelect,
},
};
/* Styling for each model view */
async function getStyling() {
const selectedModel = getSelectedModel();
const props = [];
// Apply styling to nodes and make sure all items transition from centre of chart
chart.each({ type: "node" }, (item) => {
if (defaultStyle.nodeColours[item.d.kind]) {
props.push(
Object.assign({}, defaultNodeStyle, {
id: item.id,
t: {
...defaultLabelStyle,
t: item.d.label,
},
b: defaultStyle.nodeColours[item.d.kind],
fi: {
c: defaultStyle.nodeColours[item.d.kind],
t: item.d.subkindperson
? getIconByKind(item.d.subkindperson)
: getIconByKind(item.d.kind),
},
})
);
} else {
props.push({ id: item.id, x: 0, y: 0 });
}
});
// Style links
chart.each({ type: "link" }, (item) => {
props.push(Object.assign({}, defaultLinkStyle, { id: item.id }));
});
if (selectedModel.model === "people") {
// Calculate betweenness between components
const betweenness = await chart
.graph()
.betweenness({ normalization: "component" });
// Adjust size and colour of node based on betweenness in component
const adjustments = {};
chart
.graph()
.components()
.forEach((component) => {
const length = component.nodes.length;
component.nodes.forEach((node) => {
const enlargement = ((betweenness[node] - 0.5) * length) ** 0.6;
const size = enlargement >= 1 ? enlargement : 1;
const colour = getColour(betweenness[node]);
adjustments[node] = { size, colour };
});
props.forEach((item, index) => {
if (adjustments[item.id]) {
props[index].e = adjustments[item.id].size;
props[index].fi.c = adjustments[item.id].colour;
props[index].b = adjustments[item.id].colour;
}
});
});
}
return props;
}
/* Chart filter and model transition */
async function showSelectedModel() {
const selectedModel = getSelectedModel();
if (selectedModel.model !== "garage-damages") {
// Make sure to uncombine combos and to reveal all links
chart.combo().reveal([]);
chart.combo().uncombine(getComboIds(), { time: 0, select: false });
}
// Update the chart options select node property
chart.options({ selectedNode: selectedNodeStyle[selectedModel.model] });
const transitionOptions = { time: 200 };
const filterOptions = {
time: 500,
hideSingletons: true,
};
// Filter the items for the selected model
await chart.filter(
(node) => node.d.models.includes(selectedModel.model),
filterOptions
);
// Transition to the selected model after collecting styling
const props = await getStyling();
await models[selectedModel.model].transition(props, transitionOptions);
modelElement.disabled = false;
models[selectedModel.model].onSelect(getSelection());
}
/* END Chart filter and Transition */
// Controls the item selection behaviour
function onSelection() {
chart.off("click");
chart.on("click", ({ id }) => {
models[getSelectedModel().model].onSelect(id);
});
}
function onMap({ type }) {
if (type === "showend") {
mapBaseLayer();
applyMapStyling();
}
}
function preventCombosOpening() {
if (!chart) return;
chart.on("double-click", ({ id, preventDefault }) => {
if (chart.combo().isCombo(id)) {
preventDefault();
}
});
}
async function startKeyLines() {
const chartOptions = {
logo: { u: "/images/Logo.png" },
iconFontFamily: "Font Awesome 5 Free",
imageAlignment: {
"fas fa-user": { e: 1.0, dy: -5 },
"fas fa-car": { e: 0.9, dy: -4 },
"fas fa-wrench": { e: 0.9, dy: -2 },
"fas fa-cogs": { e: 0.9, dx: -5 },
"fas fa-phone": { e: 0.9, dy: 5, dx: 3 },
"fas fa-file-invoice-dollar": { e: 0.9, dy: 0 },
"fas fa-file-contract": { e: 0.9, dy: 0 },
"fas fa-user-md": { dy: -3 },
},
selectionColour: defaultStyle.selectionColour,
selectionFontColour: defaultStyle.selectionFontColour,
defaultStyles: {
comboGlyph: comboGlyphStyle,
},
handMode: true,
arrows: "small",
minZoom: 0.02,
backColour: "#F0F8FF",
linkEnds: { avoidLabels: false },
};
chart = await KeyLines.create({
container: "klchart",
options: chartOptions,
});
// Reduce chart zoom for smoother load & initial layout animation
chart.viewOptions({ zoom: 0.05 });
// Set map options for map view
chart.map().options({
time: 250,
tiles: null, // Remove the default tile layer
transition: "restore",
leaflet: {
minZoom: 10,
},
});
chart.on("map", onMap);
onSelection();
// Load the data to the chart component
chart.load(data);
preventCombosOpening();
// Update view and selection behaviour on dropdown change
modelElement.addEventListener("change", async () => {
modelElement.disabled = true;
const modeltype = modelElement.value;
descriptorElements.forEach((element) => element.classList.add("hide"));
document.getElementById(modeltype).classList.remove("hide");
onSelection();
await chart.map().hide();
showSelectedModel();
});
// Run initial full network layout
showSelectedModel();
}
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>Insurance Fraud 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/leaflet.css">
<link rel="stylesheet" type="text/css" href="/css/fontawesome5/fontawesome.css">
<link rel="stylesheet" type="text/css" href="/css/fontawesome5/solid.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/leaflet.js" defer type="text/javascript"></script>
<script src="/vendor/esri-leaflet.js" defer type="text/javascript"></script>
<script src="/vendor/esri-leaflet-geocoder.js" defer type="text/javascript"></script>
<script src="/insurancefraudanalysis.js" crossorigin="use-credentials" 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>
</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 Fraud Analysis</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<p>Explore insurance data for fraudulent activity.</p>
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div class="cicontent">
<p>Choose a view below to investigate the data.</p>
<h4>Select view</h4>
<select class="input-block-level" id="model" disabled>
<option value="none">Full Network</option>
<option value="people">Individuals' Connections</option>
<option value="person-garage">Distances Travelled</option>
<option value="garage-damages">Damage Types</option>
</select><span class="model-text" id="none">
<p>
This view shows the raw data model.
Although we already see some clustering in this view, by remodelling the data we can reveal further insights.
</p>
<p>Select another view from the drop-down menu to see what we can do with KeyLines.</p></span><span class="model-text hide" id="people">
<p>
In this view links between individuals are dynamically created based on whether they have an item in common.
Individuals are then sized based on betweenness which is a measure of how well connected a node is.
</p>
<p>
Typical claims are small isolated clusters of people.
We can see some suspicious individuals who link multiple claims together.
</p>
<p>
While it's normal for a doctor to be involved in multiple claims, it's unusual for other
individuals - such as a witness, and may be worth further investigation.
</p></span><span class="model-text hide" id="garage-damages">
<p>
This view displays the type of damage caused, with connections to the garages that fixed
them. The damages are grouped by car part and links represent nodes with a shared claim.
</p>
<p>
By grouping the repairs based on the damaged vehicle part, we can see that one garage is
repairing a disproportionate number of OS rear doors.
This could indicate claim inflation.
</p>
<p>
Click on a
<i class="fas fa-wrench" style="font-size: 1.5em"></i>
garage to explore the types of damage it has repaired.
</p>
<p>Click anywhere on the chart background to undo the selection.</p></span><span class="model-text hide" id="person-garage">
<p>
This view places policy holders' and garages' addresses on a map.
Links are inferred by identifying paths between garages
and policy holders via damages.
</p>
<p>
By using geocoded data we can see patterns and other information that we couldn't before. Here we see
that some people are travelling long distances to get to a particular garage, which could indicate fraud.
</p></span>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
Loading source
