//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Explore attack paths and alerts on a cloud infrastructure.
import KeyLines from "keylines";
import { annotationPositions, colours, data } from "./cloudsecurity-data.js";

let chart;
let graphEngine;
let comboIds;
let currentView = "full";

const comboCrossingLinks = [
  "Internet-gateway-elb-1",
  "Internet-gateway-elb-2",
  "gateway-elb-1-lambda-2b154e",
  "gateway-elb-1-lambda-2b354e",
  "gateway-elb-2-lambda-2b154e",
  "gateway-elb-2-lambda-2b354e",
];
const comboOpenCloseOpts = { adapt: "none", animate: true, time: 70 };
const innerCombos = ["demo-private-us-west-1a", "demo-private-us-west-1f"];
const sequentialArrangeOpts = {
  ...comboOpenCloseOpts,
  animate: false,
  name: "sequential",
  tightness: 4,
  stacking: { arrange: "grid" },
  orientation: "right",
  linkShape: "angled",
};
const layoutOpts = {
  orientation: "right",
  linkShape: "angled",
};
const alertNodeIds = [
  "s3-bucket-1a",
  "s3-bucket-2a",
  "ec2-13cc45d",
  "lambda-2b154e",
  "gateway-elb-1",
  "gateway-elb-2",
  "Internet",
];
const selectedIdsSet = new Set();
const viewOptionsRadios = Array.from(
  document.querySelectorAll('input[name="viewOptions"]')
);
const alertPanel = document.getElementById("alertPanel");

async function showAlert() {
  // Hide non-alerts
  const nodesToHide = [];
  chart.each({ type: "node", items: "all" }, ({ id }) => {
    if (!alertNodeIds.includes(id) && !chart.combo().isCombo(id)) {
      nodesToHide.push(id);
    }
  });
  await chart.hide(nodesToHide, { animate: true, time: 500 });

  // Uncombine all
  await chart
    .combo()
    .uncombine("aws", { full: true, select: false, animate: false });

  await setWarningAnnotationPosition(annotationPositions.onlyAttackPath);

  // Sequential layout left to right with Internet first
  await chart.layout("sequential", { ...layoutOpts, tightness: 7 });

  // Ping s3 buckets and show alert in ne pos
  await chart.ping(["s3-bucket-1a", "s3-bucket-2a"], {
    c: colours.lightGrey,
    repeat: 3,
    time: 500,
  });
}

// Determines which items need styling updates after a selection change
function getSelectionActions(idsToSelect) {
  const idsToSelectSet = new Set(idsToSelect);
  const itemsToStyle = {};

  idsToSelectSet.forEach((id) => {
    if (!selectedIdsSet.has(id)) {
      itemsToStyle[id] = "select";
    }
  });
  selectedIdsSet.forEach((id) => {
    if (!idsToSelectSet.has(id)) {
      itemsToStyle[id] = "deselect";
    }
  });

  return itemsToStyle;
}

async function updateSelectionStyling(selectionActions) {
  const itemsToUpdate = [];

  // Update items which require re-styling
  for (const [id, action] of Object.entries(selectionActions)) {
    const item = chart.getItem(id);

    if (action === "select") {
      // Set selection styling
      item.t[0].b = colours.white;
      item.b = colours.white;
      selectedIdsSet.add(id);
    } else {
      // Undo selection styling
      item.t[0].b = colours.transparent;
      item.b = colours.transparent;
      selectedIdsSet.delete(id);
    }
    itemsToUpdate.push({ id, t: item.t, b: item.b });
  }

  await chart.setProperties(itemsToUpdate);
}

async function highlightSelection(idsToSelect) {
  const selectionActions = getSelectionActions(idsToSelect);
  updateSelectionStyling(selectionActions);

  if (idsToSelect.length > 0) {
    graphEngine.load(chart.serialize());
    const nonComboNeighbours = graphEngine.neighbours(idsToSelect);
    const links = nonComboNeighbours.links;
    chart.foreground(
      (item) =>
        links.includes(item.id) ||
        idsToSelect.includes(item.id) ||
        nonComboNeighbours.nodes.includes(item.id)
    );
  } else {
    // Clear selection
    chart.combo().reveal(comboCrossingLinks);
    chart.foreground(() => true);
  }
}

async function colourAlertItems() {
  const props = [];

  chart.each({ type: "node", items: "all" }, ({ id, u }) => {
    if (!u || chart.combo().isCombo(id)) return;
    const c = alertNodeIds.includes(id) ? colours.alert : colours.nonAlert;
    props.push({ id, c });
  });

  chart.each({ type: "link", items: "underlying" }, ({ id, id1, id2 }) => {
    if (alertNodeIds.includes(id2)) {
      props.push({ id, c: colours.alert, w: 2, priority: 1 });
    }
  });

  await chart.setProperties(props);
}

async function init(animate = true) {
  graphEngine = KeyLines.getGraphEngine();
  await chart.load(data);
  comboIds = getComboIds();
  await openAllCombos(animate);
  await updateComboCounters();
  await revealLinks();
  await chart.layout("sequential", {
    ...layoutOpts,
    animate: false,
    straighten: false,
  });
}

// Updates the warning annotation position angle via setProperties
async function setWarningAnnotationPosition(position) {
  await chart.setProperties({
    id: "s3-warning-annotation",
    position,
  });
}

async function resetToInfrastructureView() {
  if (currentView === "onlyAttackPath") {
    await init(false);
    alertPanel.classList.add("hidden");
    await setWarningAnnotationPosition(annotationPositions.infrastructure);
  }
}

// Toggles the disabled state of unselected radio inputs
function toggleUnselectedRadios(selectedRadioValue) {
  viewOptionsRadios.forEach((radio) => {
    if (radio.value !== selectedRadioValue) {
      radio.disabled = !radio.disabled;
    }
  });
}

const viewChangeHandlers = {
  full: async (e) => {
    await resetToInfrastructureView();
    await undoColourAlertItems();
    currentView = e.target.value;
  },
  fullAttackPath: async (e) => {
    await resetToInfrastructureView();
    await colourAlertItems();
    currentView = e.target.value;
  },
  onlyAttackPath: async (e) => {
    alertPanel.classList.remove("hidden");
    const selectedRadioValue = e.target.value;
    toggleUnselectedRadios(selectedRadioValue);
    if (currentView === "full") {
      await colourAlertItems();
    }
    await showAlert();
    toggleUnselectedRadios(selectedRadioValue);
    currentView = selectedRadioValue;
  },
};
async function undoColourAlertItems() {
  const props = [];
  chart.each({ type: "node", items: "all" }, ({ id, u }) => {
    if (!u || chart.combo().isCombo(id)) return;
    const iconName = u.split("/").pop().split(".").shift();
    props.push({ id, c: colours[iconName] });
  });

  chart.each({ type: "link", items: "underlying" }, ({ id, id1, id2 }) => {
    if (alertNodeIds.includes(id1) || alertNodeIds.includes(id2)) {
      props.push({ id, c: colours.lightGrey, w: 1, priority: 0 });
    }
  });

  chart.setProperties(props);
}

// Called when KeyLines is created
function initializeInteractions() {
  viewOptionsRadios.forEach((radio) => {
    radio.addEventListener("change", (e) => {
      const selectedView = e.target.value;
      viewChangeHandlers[selectedView](e);
    });
  });

  chart.on("selection-change", () => {
    const ids = chart
      .selection()
      .filter(
        (id) => chart.getItem(id).type === "node" && !chart.combo().isCombo(id)
      );
    highlightSelection(ids);
  });

  chart.on("double-click", ({ id, preventDefault }) => {
    if (!id) return;
    const parent = chart.combo().find(id, { parent: "first" });
    const isCombo = chart.combo().isCombo(id);

    if (isCombo || parent !== null) {
      preventDefault();
    }

    if (!isCombo) return;
    const comboIsOpen = chart.combo().isOpen(id);
    const op = comboIsOpen ? chart.combo().close : chart.combo().open;
    op(id, comboOpenCloseOpts);
  });
}

function getComboIds() {
  comboIds = [];
  chart.each({ type: "node", items: "all" }, ({ id }) => {
    if (chart.combo().isCombo(id)) comboIds.push(id);
  });
  return comboIds;
}

async function openAllCombos(animate) {
  await chart.combo().open(comboIds, { ...comboOpenCloseOpts, animate });
  await chart.combo().arrange(innerCombos, sequentialArrangeOpts);
  await chart.combo().arrange("demo-vpc", sequentialArrangeOpts);
}

async function updateComboCounters() {
  const props = comboIds.map((comboId) => {
    const combo = chart.getItem(comboId);
    const childNodeCount = chart.combo().info(comboId).nodes.length;
    combo.t[combo.t.length - 1].t = childNodeCount;
    return { id: comboId, t: combo.t };
  });
  await chart.setProperties(props);
}

async function revealLinks() {
  // Reveal links
  chart.combo().reveal(comboCrossingLinks);

  // Hide combo links
  const comboLinks = [];
  chart.each({ type: "link", items: "all" }, ({ id }) => {
    if (chart.combo().isCombo(id)) comboLinks.push(id);
  });

  const hideComboLinks = comboLinks.map((link) => {
    return {
      id: link,
      // use c: 'transparent' instead of hi: true so links are still considered by sequential layout
      c: "transparent",
    };
  });

  await chart.setProperties([...hideComboLinks]);
}

async function startKeyLines() {
  const options = {
    handMode: true,
    gradient: {
      stops: [
        { r: 0, c: "#246" },
        { r: 1, c: "#123" },
      ],
    },
    controlTheme: "dark",
    overview: { icon: false },
    combos: { shape: "rectangle" },
    selectedNode: {},
    selectedLink: {},
    defaultStyles: {
      comboGlyph: null,
    },
    fontFamily: "Muli",
    iconFontFamily: "Font Awesome 5 Free",
    imageAlignment: {
      "fas fa-cloud": { e: 0.88 },
    },
  };

  chart = await KeyLines.create({ container: "klchart", options });

  await init();

  initializeInteractions();
}

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>Cloud Security</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="/cloudsecurity.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="/cloudsecurity.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">
          </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%;">Cloud Security</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">
              <p>Organise your tiered data into familiar structures with rectangular combos and sequential combo arrangements. Use different link shapes to indicate types of relationship.</p>
              <p>
                Click the options below to alternate between different cloud infrastructure views, 
                and a focussed view of the attack path.
              </p>
              <fieldset>
                <legend style="margin-bottom:4px">Select view</legend>
                <label class="radio">
                  <input type="radio" name="viewOptions" value="full" checked="checked"> Full infrastructure
                </label>
                <label class="radio">
                  <input type="radio" name="viewOptions" value="fullAttackPath"> Infrastructure with attack path
                </label>
                <label class="radio">
                  <input type="radio" name="viewOptions" value="onlyAttackPath"> Attack path only
                </label>
              </fieldset>
              <fieldset class="hidden" id="alertPanel">
                <legend style="font-size: 18px">Currently showing</legend>
                <div id="identifiedAlerts"> <i class="fa fa-exclamation-triangle"></i>
                  <div>Misconfigured S3 Buckets</div>
                </div>
              </fieldset>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
#identifiedAlerts {
  padding: 14px;
  color: white;
  display: flex;
  align-items: center;
  gap: 14px;
  background-color: rgb(22, 45, 66);
  border-bottom: 2px solid rgb(252, 174, 30);
  width: 100%;
  font-weight: bold;
}

label:has(input[type='radio']:disabled),
label:has(input[type='radio']:disabled) * {
  cursor: not-allowed;
}

.fa-exclamation-triangle {
  color: rgb(252, 174, 30);
  margin-bottom: 2px;
}
Loading source