//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Explore the source and distribution of ransomware attacks over time.
import KeyLines from "keylines";
import { data, comboData, ransomwareColours } from "./ransomware-data.js";

let chart;
let timebar;

// Scale the closed combo sizes at slightly less than the square root of their
// node count so that the larger combos (e.g. USA) don't dominate too much
// and the smaller combos aren't too tiny
const scalePower = 0.4;

function isCombo(ids) {
  return chart.combo().isCombo(ids, { type: "node" });
}

function showRansomware(item) {
  const checked = [
    ...document.querySelectorAll('input[name="ransomware"]:checked'),
  ];
  return checked.some((c) => item.d.ransomware === c.id);
}

function enableButton(type, enable) {
  document.getElementById(`${type}Run`).disabled = !enable;
}

function layout(mode = "full") {
  const layoutType = document.getElementById("layoutType").value;
  const opts = layoutType === "organic" ? { mode } : {};
  chart.layout(layoutType, opts);
}

function timebarFilterCriteria(item) {
  // If the ransomware is not checked
  if (item.d.ransomware && !showRansomware(item)) {
    return false;
  }
  if (item.d.type === "ip") {
    const hosts = item.d.host;
    return hosts.some((host) => timebar.inRange(host));
  }
  if (item.d.type === "host") {
    return timebar.inRange(item.id);
  }
  return false; // Other cases should return false
}

async function timebarChange() {
  // filter the chart to show only items in the new range
  const changes = await chart.filter(timebarFilterCriteria, {
    type: "node",
    animate: false,
    hideSingletons: true,
  });
  // update the size of the combo based on the changes object returned by the filter
  const visualProperties = changes.combos.nodes.map((comboInfo) => ({
    id: comboInfo.id,
    e: comboInfo.nodes.length ** scalePower,
  }));

  // When playing the timebar sometimes it can be helpful to overwrite previous animations
  // => queue: false
  await chart.animateProperties(visualProperties, { time: 300, queue: false });
}

function isOnlyCombos(items) {
  return items.length && items.every((item) => isCombo(item.id));
}

function isOnlyTypeSelected(type, items) {
  return items.every((item) => item.d.type === type);
}

function groupByRansomware(items) {
  const result = {};
  items.forEach((item) => {
    const group = item.d.ransomware;

    if (result[group]) {
      result[group].push(item);
    } else {
      result[group] = [item];
    }
  });
  return result;
}

function ransomwareSelection(nodes, countrySelected, subsetHost) {
  return Object.entries(groupByRansomware(nodes)).map(
    ([name, group], index) => {
      let ids;
      const firstItem = group[0];
      if (isCombo(firstItem.id)) {
        if (countrySelected) {
          ids = firstItem.d.lookup[countrySelected].filter((id) =>
            subsetHost ? subsetHost.includes(id) : true
          );
        } else {
          // Selected a ransomware
          ids = [];
          Object.values(firstItem.d.lookup).forEach((i) => {
            ids = ids.concat(i);
          });
        }
      } else {
        ids = group.map((item) => item.id);
      }
      return { id: ids, index, c: ransomwareColours[name] };
    }
  );
}

function chartSelectionChange() {
  // Reset the previous timebar selection
  timebar.selection([]);
  // Get only the nodes within the selection
  const selection = chart.getItem(chart.selection());
  // Enable or disable the uncombine button
  enableButton("uncombine", isOnlyCombos(selection));

  let tbSelection = []; // The new selection of the timebar

  // show the trend of the ransomwares for the country combo and ip nodes
  if (isOnlyTypeSelected("ip", selection)) {
    const neighbours = {};
    const hosts = {};

    // For each country
    selection.forEach((ip) => {
      neighbours[ip.d.country] = neighbours[ip.d.country] || [];
      neighbours[ip.d.country] = neighbours[ip.d.country].concat(
        chart.graph().neighbours(ip.id).nodes
      );

      if (ip.d.host) {
        hosts[ip.d.country] = hosts[ip.d.country] || [];
        hosts[ip.d.country] = hosts[ip.d.country].concat(ip.d.host);
      }
    });

    // For every neighbour to selected countries
    Object.entries(neighbours).forEach(([country, nodes]) => {
      // Get the timebar selection
      const newSelection = ransomwareSelection(
        chart.getItem(nodes),
        country,
        hosts[country]
      );
      tbSelection = tbSelection.concat(newSelection);
    });
  }

  // Show the trend for the ransomware combos and host nodes
  if (isOnlyTypeSelected("host", selection)) {
    tbSelection = ransomwareSelection(selection);
  }

  timebar.selection(tbSelection); // Set the new selection to the timebar
}

function selectionChangeAndLayout() {
  chartSelectionChange();
  layout("adaptive");
}

async function filterRansomware() {
  await timebarChange();
  selectionChangeAndLayout();
}

function uncombineAction() {
  chart.combo().uncombine(chart.selection(), { animate: false, select: false });
  // run the filter of ransomware
  filterRansomware();
  // Enable the combine button
  enableButton("combine", true);
}

async function combineAction() {
  // Find combos that have been uncombined
  // Filter out ones that are still combos as otherwise, Keylines raises an error
  const toBeCombined = comboData.filter(
    ({ ids }) => chart.combo().find(ids[0]) === null
  );
  await chart
    .combo()
    .combine(toBeCombined, {
      select: false,
      animate: false,
      arrange: "concentric",
    });
  // run the filter of ransomware
  filterRansomware();
  // Disable the combine button
  enableButton("combine", false);
}

function initialiseEvents() {
  chart.on("selection-change", chartSelectionChange); // Align the chart selection to the timebar

  timebar.on("change", timebarChange); // Filter the nodes to match the timebar range

  document
    .getElementById("layoutRun")
    .addEventListener("click", () => layout()); // Run the layout
  document
    .getElementById("layoutType")
    .addEventListener("change", () => layout()); // Run the layout when choosing a layout
  document
    .getElementById("combineRun")
    .addEventListener("click", combineAction); // Combine all the nodes
  document
    .getElementById("uncombineRun")
    .addEventListener("click", uncombineAction); // Uncombine selected nodes
  document.querySelectorAll('input[name="ransomware"]').forEach((btn) => {
    // Filter the ransomwares
    btn.addEventListener("change", filterRansomware);
    btn.addEventListener("keyup", filterRansomware);
  });
}

async function startKeyLines() {
  const chartOptions = {
    logo: "/images/Logo.png",
    hover: 100,
    marqueeLinkSelection: "off",
    imageAlignment: {
      "/images/icons/virus.svg": { e: 0.9 },
    },
    selectedNode: {
      fb: true,
      ha0: {
        c: "#555",
        r: "31",
        w: "3",
      },
      oc: { bw: 20 },
    },
    iconFontFamily: "Font Awesome 5 Free",
    handMode: true,
  };

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

  chart.load(data);
  timebar.load(data);

  // Fit the initial view to the loaded data
  chart.zoom("fit", { animate: false });
  timebar.zoom("fit", { animate: false });

  combineAction();
  initialiseEvents();
}

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>Ransomware Attacks</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="/ransomware.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="/ransomware.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 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%;">Ransomware Attacks</text>
          </svg>
        </div>
        <div class="tab-content-panel" data-tab-group="rhs">
          <div class="toggle-content is-visible tabcontent" id="controlsTab">
            <p>Explore the source and distribution of ransomware attacks over time:</p>
            <form autocomplete="off" onsubmit="return false" id="rhsForm">
              <div class="cicontent">
                <ul>
                  <li>select a ransomware family or country to show time-based activity</li>
                  <li>double-click ransomware families or countries to explore malware hosts</li>
                  <li>use time bar controls to filter the chart</li>
                  <li>choose the best layout to display filtered data</li>
                </ul><br>
                <div class="form-inline">
                  <select class="inline input-medium" id="layoutType">
                    <option value="organic">Organic</option>
                    <option value="structural">Structural</option>
                  </select>
                  <input class="btn" type="button" value="Layout" id="layoutRun">
                </div><br>
                <p>Filter by ransomware family:<br>
                  <label class="checkbox inline">
                    <input type="checkbox" id="TeslaCrypt" name="ransomware" checked="checked"><span class="label" style="background: #FF5964">TeslaCrypt</span>
                  </label>
                  <label class="checkbox inline">
                    <input type="checkbox" id="CryptoWall" name="ransomware" checked="checked"><span class="label" style="background: #00BFFF">CryptoWall</span>
                  </label>
                  <label class="checkbox inline">
                    <input type="checkbox" id="Locky" name="ransomware" checked="checked"><span class="label" style="background: #A5DF00">Locky</span>
                  </label>
                </p><br>
                <p>Uncombine selected combos, or combine all.</p>
                <input class="btn" type="button" value="Uncombine" disabled="disabled" id="uncombineRun">
                <input class="btn" type="button" value="Combine All" disabled="disabled" id="combineRun">
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
#layoutRun {
  margin-top: 0;
}

.checkbox.inline {
  vertical-align: middle;
}

.checkbox input[type='checkbox'] {
  float: none;
  margin-right: 4px;
}
.form-inline * {
  margin: 0px;
  vertical-align: middle;
}
Loading source