//
//     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