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