//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//!    Explore relations between cyber attackers and their targets.
import KeyLines from "keylines";
import { data } from "./databreaches-data.js";

let chart;
let timebar;
let suppressedChecks = [];

const linkColours = [
  "rgba(255, 0, 13, 0.7)",
  "rgba(252, 132, 39, 0.7)",
  "rgba(255, 207, 9, 0.7)",
  "rgba(33, 252, 13, 0.7)",
  "rgba(0, 253, 255, 0.7)",
  "rgba(229, 153, 255, 0.7)",
  "rgba(227, 19, 254, 0.7)",
  "rgba(186, 153, 15, 0.7)",
];

const orange = "rgb(255, 127, 14)";

const typeToCategories = {
  advanced: {
    "Web application": 1,
    "Remote access": 1,
    "Backdoor or C2": 1,
    "Command shell": 1,
    VPN: 1,
    "Web drive-by": 1,
  },
  basic: {
    "LAN access": 1,
    "Desktop sharing": 1,
    Phone: 1,
    Documents: 1,
    "Direct install": 1,
    "3rd party desktop": 1,
    "Software update": 1,
  },
  careless: {
    Carelessness: 1,
    "Inadequate processes": 1,
    "Inadequate technology": 1,
    "Non-corporate": 1,
  },
  vArea: {
    "Victim work area": 1,
    "Victim public area": 1,
    "Victim grounds": 1,
    "Victim secure area": 1,
  },
  tArea: { "Public facility": 1, "Partner facility": 1, "Public vehicle": 1 },
  email: {
    Email: 1,
    "Email attachment": 1,
    "Email autoexecute": 1,
    "Email link": 1,
  },
  physical: {
    "Physical access": 1,
    "Personal residence": 1,
    "Personal vehicle": 1,
    "In-person": 1,
  },
  unknown: { Unknown: 1, Other: 1, "Random error": 1 },
};

// later we'll reverse the dictionary above to quickly filter items
const categoryToType = {};

function linkColoursByNode(nodes, links) {
  const linksByNode = {};
  nodes.forEach((node) => {
    linksByNode[node.id] = [];
  });

  links.forEach((link) => {
    if (link && link.c) {
      const linkColour = link.c;
      if (linksByNode[link.id1]) {
        linksByNode[link.id1].push(linkColour);
      }
      if (linksByNode[link.id2]) {
        linksByNode[link.id2].push(linkColour);
      }
    }
  });

  return linksByNode;
}

/**
 * Given an async function (fn), this function returns
 * a new function that will queue up to 1 call to fn when
 * invoked concurrently.
 */
function asyncThrottle(fn) {
  // 0 = ready, 1 = running, 2 = queued
  let state = 0;

  const run = async () => {
    if (state > 0) {
      state = 2;
    } else {
      state = 1;
      await fn();
      const queued = state > 1;
      state = 0;
      if (queued) run();
    }
  };

  return run;
}

function doLayout(mode) {
  return chart.layout("organic", {
    time: 300,
    easing: "linear",
    mode,
    tightness: 8,
  });
}

function getDonutsForActorNodes(nodes, linksByNode) {
  return nodes.map((node) => {
    const donutValues = [0, 0, 0, 0, 0, 0, 0, 0];
    const linkColourList = linksByNode[node.id];
    if (linkColourList) {
      linkColourList.forEach((linkColour) => {
        const index = linkColours.indexOf(linkColour);
        if (index !== -1) {
          donutValues[index]++;
        }
      });
    }
    return { id: node.id, donut: { v: donutValues } };
  });
}

function getActorNodeIdsInRange(linkIdsInRange) {
  const allNodeIds = [];
  linkIdsInRange.forEach((id) => {
    const link = chart.getItem(id);
    if (link && !allNodeIds.includes(link.id1)) allNodeIds.push(link.id1);
    if (link && !allNodeIds.includes(link.id2)) allNodeIds.push(link.id2);
  });

  return allNodeIds.filter((id) => {
    const node = chart.getItem(id);
    return node.d.type === "actor";
  });
}

function updateDonuts() {
  const range = timebar.range();

  // find the attacks that occured within the timebar's range
  const linkIdsInRange = timebar.getIds(range.dt1, range.dt2);
  const linksInRange = chart.getItem(linkIdsInRange);

  // find the actor nodes that are adjacent to those links
  const actorNodeIdsInRange = getActorNodeIdsInRange(linkIdsInRange);
  const actorNodesInRange = chart.getItem(actorNodeIdsInRange);

  // get an object listing all the link colours for each of those actor nodes
  const actorLinkColours = linkColoursByNode(actorNodesInRange, linksInRange);

  // use the lists of link colours to make donuts for those actor nodes
  const donutsToUpdate = getDonutsForActorNodes(
    actorNodesInRange,
    actorLinkColours
  );
  chart.animateProperties(donutsToUpdate, { time: 300, easing: "cubic" });
}

function resetTimebarSelection() {
  timebar.selection([]);
}

function itemsAndNeighbours(ids) {
  const result = {};
  const items = chart.getItem(ids);

  const neighbourIds = chart.graph().neighbours(ids, { all: true });

  neighbourIds.links.concat(neighbourIds.nodes).forEach((neighbourId) => {
    result[neighbourId] = true;
  });

  items.forEach((item) => {
    result[item.id] = true;
  });
  return result;
}

function neighbouringCriterion(ids) {
  const idsToForeground = itemsAndNeighbours(ids);
  return (item) => idsToForeground[item.id];
}

// foreground/background the chart items based on whether they neighbour a checked attack vector
function foregroundCheckedAttackVectors() {
  // make all checkboxes determinate
  document.querySelectorAll(".vector input").forEach((checkbox) => {
    checkbox.indeterminate = false;
  });
  // re-check suppressed checkboxes
  suppressedChecks.forEach((checkbox) => {
    checkbox.checked = true;
  });
  suppressedChecks = [];

  const threshold = 3;

  // first read the number of checkboxes checked
  const checked = document.querySelectorAll(".vector input:checked");
  // if there are more than 3 uncheck this last one and return
  if (checked.length > threshold) {
    // just exit
    this.checked = false;
    return;
  }

  document
    .querySelectorAll(".vector input:not(:checked)")
    .forEach((checkbox) => {
      checkbox.disabled = checked.length === threshold;
    });

  resetTimebarSelection();

  const criteria = [];

  if (checked.length) {
    const checkedTypes = {};

    checked.forEach((el, i) => {
      // save type -> selection object in this dictionary
      checkedTypes[el.id] = {
        id: [],
        index: i,
        c: el.parentElement.querySelector("span.color-legend").style
          .backgroundColor,
      };
    });

    chart.each({ type: "link" }, (link) => {
      const type = categoryToType[link.d.type];
      if (type in checkedTypes) {
        checkedTypes[type].id.push(link.id);
      }
    });

    const selectionList = [];

    Object.keys(checkedTypes).forEach((index) => {
      const ids = checkedTypes[index].id;

      criteria.push(neighbouringCriterion(ids));

      selectionList.push(checkedTypes[index]);
    });

    timebar.selection(selectionList);

    // foreground the checked vectors
    chart.foreground((item) => criteria.some((criterion) => criterion(item)));
  } else {
    // no vector checkboxes are checked, so foreground everything
    chart.foreground(() => true);
  }
}

function forEachVictimNode(fn) {
  chart.each({ type: "node" }, (node) => {
    if (node.d.type === "victim") {
      fn(node);
    }
  });
}

async function resetVector(e) {
  // reset the checkboxes
  document.querySelectorAll(".vector input").forEach((input) => {
    input.checked = false;
    input.disabled = false;
    input.indeterminate = false;
  });
  // clear the chart selection
  chart.selection([]);

  resetTimebarSelection();
  chart.foreground(() => true);
  // reset the size of companies as well
  const changes = [];
  forEachVictimNode((node) => {
    changes.push({ id: node.id, e: 1 });
  });
  await chart.animateProperties(changes, {});
  doLayout("adaptive");
  e.preventDefault();
}

function normalize(value, min, max) {
  if (min === max) {
    return value;
  }
  return (value - min) / (max - min);
}

async function sizeByCompanyVectors() {
  const degrees = chart.graph().degrees();
  const changes = [];
  let max = -Infinity;
  let min = Infinity;
  // first pass: find the max and min degrees
  forEachVictimNode((node) => {
    if (node.id in degrees) {
      max = Math.max(max, degrees[node.id]);
      min = Math.min(min, degrees[node.id]);
    }
  });
  // second pass, now size the nodes
  forEachVictimNode((node) => {
    if (node.id in degrees) {
      changes.push({
        id: node.id,
        e: 1 + 6 * normalize(degrees[node.id], min, max),
      });
    }
  });
  await chart.animateProperties(changes, { time: 800 });
  doLayout("adaptive");
}

const filterOnTimebarChange = asyncThrottle(async () => {
  // filter the chart to show only items in the new range
  await chart.filter(timebar.inRange, { animate: false, type: "link" });
  updateDonuts();
  await doLayout("adaptive");
});

function foregroundOnSelectionChange() {
  resetTimebarSelection();
  const selection = chart.selection();

  if (selection.length) {
    // foreground the selected items, and any neighbours thereof
    const foreground = itemsAndNeighbours(selection);

    // find all the attack types that have a link in the foreground
    const selectedAttackTypes = [];
    chart.foreground(
      (link) => {
        if (foreground[link.id]) {
          const type = categoryToType[link.d.type];
          selectedAttackTypes.push(type);
          return true;
        }
        return false;
      },
      { type: "link" }
    );

    document.querySelectorAll(".vector input").forEach((checkbox) => {
      // for the selected attack types, make the corresponding checkboxes indeterminate
      checkbox.indeterminate = selectedAttackTypes.includes(checkbox.id);
      // uncheck any other checked checkboxes
      if (checkbox.checked && !checkbox.indeterminate) {
        checkbox.checked = false;
        // record that we unchecked this checkbox, so we can re-check it later
        suppressedChecks.push(checkbox);
      }
    });
  } else {
    // In this case, the click was on the chart background,
    // so we do the foregrounding in accordance with checkbox state.
    foregroundCheckedAttackVectors();
  }
}

async function klReady(components) {
  [chart, timebar] = components;

  chart.load(data);
  chart.zoom("fit");

  timebar.load(data);
  await timebar.zoom("fit", { time: 100 });

  // Setup Filters
  // when the time bar range changes, filter the chart accordingly
  timebar.on("change", filterOnTimebarChange);
  // handle clicks by foregrounding the selected item(s) and neighbours thereof
  chart.on("selection-change", foregroundOnSelectionChange);

  // reverse the typeToCategory dictionary
  Object.keys(typeToCategories).forEach((type) => {
    const categories = typeToCategories[type];
    Object.keys(categories).forEach((category) => {
      categoryToType[category] = type;
    });
  });

  // Vector Filter
  document.querySelectorAll(".vector input").forEach((input) => {
    input.addEventListener("change", foregroundCheckedAttackVectors, false);
    input.addEventListener("keyup", foregroundCheckedAttackVectors, false);
  });

  document
    .getElementById("reset")
    .addEventListener("click", resetVector, false);
  // add an explanation to the vector categories
  document.querySelectorAll(".vector").forEach((el) => {
    const categories =
      typeToCategories[el.querySelector("input").getAttribute("id")];
    const names = Object.keys(categories);
    const label = el.querySelector("span.text-legend");
    const wrapperSpan = el.querySelector("span.popover-wrapper");
    wrapperSpan.setAttribute("data-title", label.textContent);
    wrapperSpan.setAttribute("data-content", names.join(", "));
  });

  // Layout button
  document.getElementById("layout").addEventListener("click", doLayout, false);
  document
    .getElementById("degrees")
    .addEventListener("click", sizeByCompanyVectors, false);
}

async function startKeyLines() {
  const attackerIcon = "fas fa-users";
  const chartOptions = {
    controlTheme: "dark",
    drag: {
      links: false,
    },
    handMode: true,
    iconFontFamily: "Font Awesome 5 Free",
    overview: { icon: false, shown: false },
    minZoom: 0.01,
    selectionColour: orange,
    linkEnds: { avoidLabels: false },
    imageAlignment: {},
    backColour: "#2d383f",
  };

  chartOptions.imageAlignment[attackerIcon] = {
    e: 0.8,
  };

  const timeBarOptions = {
    area: { colour: "#FFFFFF" },
    backColour: "#2d383f",
    controlBarTheme: "dark",
    scale: { highlightColour: "#475259" },
    playSpeed: 50,
    sliders: "none",
    type: "area",
  };

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

function loadFontsAndStart() {
  document.fonts.load('24px "Font Awesome 5 Free"').then(startKeyLines);
}

window.addEventListener("DOMContentLoaded", loadFontsAndStart);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
  <head>
    <meta charset="UTF-8">
    <title>Filter Data Breaches</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="databreaches.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/bootstrap.js" defer type="text/javascript"></script>
    <script src="/databreaches.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>
        </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%;">Filter Data Breaches</text>
          </svg>
        </div>
        <div class="tab-content-panel" data-tab-group="rhs">
          <div class="toggle-content is-visible tabcontent" id="controlsTab">
            <form autocomplete="off" onsubmit="return false" id="rhsForm">
              <div class="cicontent">
                <fieldset>
                  <legend>Attack vectors legend:</legend>
                  <p>Select up to three attack vectors to compare.</p>
                  <ul class="legend" style="margin-left: 4px">
                    <li>
                      <label class="checkbox vector">
                        <input type="checkbox" id="advanced" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(255, 0, 13)">&nbsp;</span><span class="text-legend">Advanced tech</span></span>
                      </label>
                    </li>
                    <li>
                      <label class="checkbox vector">
                        <input type="checkbox" id="basic" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(252, 132, 39)">&nbsp;</span><span class="text-legend">Basic Tech</span></span>
                      </label>
                    </li>
                    <li>
                      <label class="checkbox vector">
                        <input type="checkbox" id="careless" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(255, 207, 9)">&nbsp;</span><span class="text-legend">Carelessness</span></span>
                      </label>
                    </li>
                    <li>
                      <label class="checkbox vector">
                        <input type="checkbox" id="vArea" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(33, 252, 13)">&nbsp;</span><span class="text-legend">Victim area</span></span>
                      </label>
                    </li>
                    <li>
                      <label class="checkbox vector">
                        <input type="checkbox" id="tArea" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(0, 253, 255)">&nbsp;</span><span class="text-legend">Third party facility</span></span>
                      </label>
                    </li>
                    <li>
                      <label class="checkbox vector">
                        <input type="checkbox" id="email" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(229, 153, 255)">&nbsp;</span><span class="text-legend">Email</span></span>
                      </label>
                    </li>
                    <li>
                      <label class="checkbox vector">
                        <input type="checkbox" id="physical" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(227, 19, 254)">&nbsp;</span><span class="text-legend">Physical access</span></span>
                      </label>
                    </li>
                    <li>
                      <label class="checkbox vector">
                        <input type="checkbox" id="unknown" name="vector"><span class="popover-wrapper" data-toggle="popover"><span class="color-legend" style="background: rgb(186, 153, 15)">&nbsp;</span><span class="text-legend">Unknown</span></span>
                      </label>
                    </li>
                  </ul>
                  <input class="btn btn-spaced" type="button" value="Clear" id="reset">
                  <input class="btn btn-spaced" type="button" value="Layout" id="layout">
                  <input class="btn btn-spaced" type="button" value="Size Companies" id="degrees">
                </fieldset>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
ul.legend {
  margin-top: 14px;
  margin-bottom: 15px;
  list-style: none;
  padding: 0px;
}

ul.legend li {
  height: 30px;
}
ul.legend input {
  margin-top: 9px;
}
ul.legend span.color-legend {
  color: #fff;
  display: inline-block;
  font-weight: bold;
  margin-right: 5px;
  margin-left: 0px;
  padding: 4px;
  width: 28px;
  height: 28px;
  border-radius: 28px;
}

.highlight {
  font-weight: bold;
}

.color-legend {
  margin-left: 5px;
}

#victimName {
  font-size: 14px;
}

.typeahead {
  max-width: 161px;
  min-width: 161px;
  border: 1px solid #ccc
}

.typeahead li {
  font-size: 14px;
  background-color: transparent;
  padding: 2px 5px;
  width: 100%;
}
.typeahead li a {
  color: #009968;
  width: 100%;
}
.typeahead li:hover a {
  color: #fff;
}
.typeahead li.active a {
  color: #fff;
}
.typeahead li.active {
  background-color: #009968;
}

.popover {
  display: none;
  height: 80px;
  width: 250px;
  font-size: 16px;
  line-height: 16px;
  margin: 0px;
  border: 1px solid #ccc;
  z-index: 1000;
  margin-bottom: -80px;
}

.highlight {
  font-weight:bold;
}

.klchart, #fullscreen.fullscreenrow .cichart, #fullscreen.fullscreenrow .klchart {
  border: none;
  background-color: #2d383f;
}

.kltimebar {
  border: none;
  border-top: dashed 1px grey;
  background-color: #2d383f;
}

.popover .popover-title {
  padding: 4px;
  margin: 0px;
  font-size: 16px;
  line-height: 16px;
  width: 100%
}


.popover .popover-content {
  padding: 4px;
  margin: 0px;
  font-size: 14px;
  line-height: 14px;
  background-color: #fff;
  height: 54px;
  width: 100%;
}

.arrow {
  background-color: #fff;
  border-top: 1px solid #ccc;
  border-right: 1px solid #ccc;
  transform: translateX(244px) translateY(35px) rotateZ(45deg);
  width: 10px;
  height: 10px;
  position: absolute;
}
Loading source