//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Investigate the source of credit card fraud.
import KeyLines from "keylines";
import { data } from "./creditcard-data.js";

let components;
let chart;
let timebar;

const red = "rgba(214, 39, 40, 0.75)";
const blue = "rgb(31, 119, 180)";
const green = "rgba(44, 160, 44, 0.75)";
const orange = "rgb(255, 127, 14)";
const lightBlue = "rgb(174, 199, 232)";
const black = "rgb(0, 0, 0)";
const tooltipElements = {
  tooltipEl: document.getElementById("tooltip"),
  transactionsEl: document.getElementById("tooltip-transactions"),
  valueEl: document.getElementById("tooltip-value"),
  arrowEl: document.querySelector(".tooltip-arrow"),
  innerEl: document.querySelector(".tooltip-inner"),
};

const linkAmounts = {};

function doLayout(adaptive) {
  return adaptive
    ? chart.layout("organic", {
        time: 500,
        straighten: false,
        easing: "linear",
        mode: "adaptive",
      })
    : chart.layout("organic", {
        time: 700,
        straighten: false,
        packing: "circle",
      });
}

// Ping nodes that are in the selected timebar 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 }
    );
  }
}

function findTotalValue(index, dt1, dt2) {
  const idsInRange = timebar.getIds(dt1, dt2);
  let total = 0;
  let ids;

  if (typeof index === "number") {
    // We're hovering over a selection point, so we want ids that are within the date range AND in
    // the selection
    ids = timebar
      .selection()
      [index].filter((item) => idsInRange.includes(item));
  } else {
    // We're hovering over a bar, not a selection point, so just need items within the date range
    ids = idsInRange;
  }

  for (let i = 0; i < ids.length; i++) {
    total += linkAmounts[ids[i]];
  }

  return total;
}

function hideTooltip() {
  tooltipElements.tooltipEl.classList.add("fadeout");
}

function showTooltip(hoverEvent) {
  // tooltipX/Y is the recommended position to place a tooltip
  const {
    type,
    index,
    value,
    tooltipX,
    tooltipY,
    rangeStart,
    rangeEnd,
  } = hoverEvent;
  const toShow = type === "bar" || type === "selection";
  if (toShow) {
    const {
      arrowEl,
      innerEl,
      tooltipEl,
      transactionsEl,
      valueEl,
    } = tooltipElements;
    // Change the tooltip content
    const totalAmount = findTotalValue(index, rangeStart, rangeEnd);
    transactionsEl.innerHTML = `${value}`;
    valueEl.innerHTML = `$${totalAmount}`;
    // Selection colour or default colour for bar hover
    let tooltipColour = blue;
    if (index === 0) {
      tooltipColour = red;
    }
    if (index === 1) {
      tooltipColour = green;
    }
    // Style both the tooltip body and the arrow
    arrowEl.style.borderTopColor = tooltipColour;
    innerEl.style.backgroundColor = tooltipColour;
    // 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();
  }
}

// Update the glyph value for merchant nodes
function doDegrees() {
  const properties = [];
  const degrees = chart.graph().degrees({ value: "amount" });

  chart.each({ type: "node" }, (node) => {
    if (node.d.label === "merchant") {
      properties.push({
        id: node.id,
        g: [
          {
            w: true,
            p: "ne",
            fc: black,
            c: lightBlue,
            b: blue,
            t: `$${degrees[node.id]}`,
          },
        ],
      });
    }
  });

  chart.setProperties(properties);
}

function doSelection() {
  const chartSel = chart.selection();

  if (chartSel.length) {
    // We need to end up with a list of disputed link ids and a list of ok link ids
    // and use those for the time bar selections.
    // If links are selected they can go into the selections, but ignore when multi-select
    // has an affect on the foregrounding

    let i;
    let j;
    const neighbours = {};
    const selectedNodes = [];
    const neighboursCount = {};
    const neighboursToInclude = [];
    const disputed = [];
    const ok = [];

    // A selected item could be a node or a link
    for (i = 0; i < chartSel.length; i++) {
      const id = chartSel[i];
      const item = chart.getItem(id);
      if (item && item.type === "link") {
        // Links go straight into the relevant selection arrays
        if (item.d.status === "Disputed") {
          disputed.push(id);
        } else {
          ok.push(id);
        }
        // Always include a selected link's nodes
        neighboursToInclude[item.id1] = 1;
        neighboursToInclude[item.id2] = 1;
      } else {
        selectedNodes.push(item);
        // Save the selected node's neighbours
        neighbours[id] = chart.graph().neighbours(id, { all: true });
      }
    }

    for (i = 0; i < selectedNodes.length; i++) {
      const node = selectedNodes[i];
      // split neighbouring links into 2, disputed and ok.
      const links = neighbours[node.id].links;
      for (j = 0; j < links.length; j++) {
        const link = chart.getItem(links[j]);
        if (link.d.status === "Disputed") {
          disputed.push(link.id);
        } else {
          ok.push(link.id);
        }
      }
      const nodes = neighbours[node.id].nodes;
      for (j = 0; j < nodes.length; j++) {
        const nodeId = nodes[j];
        // For neighbouring nodes, keep score of how many of the selected items have them as
        // neighbours
        if (!neighboursCount[nodeId]) {
          neighboursCount[nodeId] = 0;
        }
        neighboursCount[nodeId]++;
      }
    }

    // If a neighboursCount matches the selectedNodes length then we can include it
    Object.keys(neighboursCount).forEach((countedId) => {
      if (neighboursCount[countedId] === selectedNodes.length) {
        neighboursToInclude[countedId] = 1;
      }
    });

    // Foreground just the selected node and its neighbours
    chart.foreground(
      (node) => neighbours[node.id] || neighboursToInclude[node.id]
    );

    // Add the timebar selections
    timebar.selection([
      { id: disputed, index: 0, c: red },
      { id: ok, index: 1, c: green },
    ]);
  } else {
    // Nothing selected, reset
    chart.foreground(() => true);
    timebar.selection([]);
  }

  // Push the label of the selected nodes down to separate them from the selection halo
  chart.setProperties(
    data.items
      .filter((item) => item.type === "node")
      .map((item) => {
        item.t[0].margin = { top: chartSel.includes(item.id) ? 3 : 0 };
        return {
          id: item.id,
          t: item.t,
        };
      })
  );
}

async function doFilter() {
  await chart.filter(timebar.inRange, { type: "link", animate: false });
  doLayout(true);
  doSelection();
  doDegrees();
}

// Retrieve transaction amounts on links to display on tooltips
function setLinkAmounts() {
  chart.each({ type: "link" }, (link) => {
    linkAmounts[link.id] = link.d.amount ? link.d.amount : 0;
  });
}

function initialiseInteractions() {
  doSelection();
  setLinkAmounts();
  // Remove present tooltip and filter the chart as the timebar changes
  timebar.on("change", () => {
    hideTooltip();
    doFilter();
  });

  // Display tooltip and ping relevant nodes
  timebar.on("hover", (hoverEvent) => {
    showTooltip(hoverEvent);
    pingSelection(hoverEvent);
  });

  // Foreground the chart selection
  chart.on("selection-change", doSelection);

  // Reset the chart and timebar
  document.getElementById("reset").addEventListener("click", () => {
    chart.selection([]);
    doSelection();
    timebar.zoom("fit", {});
  });

  // 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);
    doSelection();
  });

  // Run a chart layout
  document.getElementById("layout").addEventListener("click", () => {
    doLayout(false);
  });
}

async function startKeyLines() {
  const options = {
    // Use this property to control the amount of alpha blending to apply to background items
    backgroundAlpha: 0.1,
    iconFontFamily: "Font Awesome 5 Free",
    logo: "/images/Logo.png",
    selectionColour: orange,
    drag: {
      links: false,
    },
  };

  const tbOptions = {
    showExtend: true,
    playSpeed: 30,
    minScale: { units: "day", value: 1 },
    groups: {
      groupBy: "status",
      categories: ["Disputed"],
      colours: ["#ea7378"],
    },
  };

  // Font Icons and images can often be poorly aligned,
  // set offsets to the icons to ensure they are centred correctly
  const imageAlignment = {
    "fas fa-shopping-cart": { e: 0.75, dx: -10 },
    "fas fa-user": { dy: -10 },
  };
  options.imageAlignment = imageAlignment;

  components = await KeyLines.create([
    { container: "klchart", type: "chart", options },
    { container: "kltimebar", type: "timebar", options: tbOptions },
  ]);

  chart = components[0];
  timebar = components[1];

  chart.load(data);
  await timebar.load(data);
  await timebar.zoom("fit", {});
  await doLayout(false);

  initialiseInteractions();
}

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>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>
                  </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">
            <p>Explore credit card transactions to find the origin of fraud.</p>
            <form autocomplete="off" onsubmit="return false" id="rhsForm">
              <div class="cicontent">
                <fieldset>
                  <legend>Options</legend>
                  <input class="btn btn-block" type="button" value="Select people with disputes" id="select">
                  <input class="btn btn-block" type="button" value="Layout" id="layout">
                  <input class="btn btn-block" type="button" value="Reset" id="reset">
                </fieldset>
              </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