//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Help users identify and understand alerts in your networks.
import KeyLines from "keylines";
import { data, colours } from "./networkalerts-data.js";

let chart;
let graph;

// State tracking
let alwaysRevealedLinks = [];
const openCombos = {};
let visiblePorts = [];

// Map data values to strings for the Details panel
const osMap = {
  win: "Windows",
  mac: "macOS",
  linux: "Linux",
  phone: "Android",
};

const typeMap = {
  linux: "Computer",
  win: "Computer",
  mac: "Computer",
  printer: "Printer",
  phone: "Smartphone",
  server: "Server",
};

function getComboShape() {
  return document.querySelector('input[name="shape"]:checked').value;
}

function getComboArrangement() {
  const comboShape = getComboShape();
  const isCircle = comboShape === "circle";
  return isCircle ? "concentric" : "grid";
}

// Find all the links that have alerts attached.
function getAlertLinks() {
  const alerts = [];
  chart.each({ type: "link" }, (link) => {
    if (link.d.alert) {
      alerts.push(link.id);
    }
  });
  return alerts;
}

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

function decorateAlertLinks(linkIds) {
  chart.setProperties(
    linkIds.map((id) => ({
      id,
      c: colours.alertRed,
      g: [
        {
          c: colours.alertRed,
          b: colours.glyphBorder,
          fi: { t: "fas fa-exclamation", c: colours.glyphFontColor },
          e: 1.5,
        },
      ],
    }))
  );
}

// Update the Details panel with details of a given item.
function updateUI(item) {
  // hide any details that are already shown
  document.querySelectorAll(".details").forEach((detail) => {
    detail.classList.add("hide");
  });

  let root;
  if (item) {
    const { type } = item.d;
    // For combo nodes, just display some very basic information
    if (chart.combo().isCombo(item.id)) {
      root = document.querySelector(".combo.details");

      // display the type of item
      root.querySelector(".field.combo-type label").textContent =
        type || "Connection";

      // and the label, if a node, or the labels of the nodes at either end if a link
      const content =
        item.type === "link"
          ? `${chart.getItem(item.id1).t[0].t} <--> ${
              chart.getItem(item.id2).t[0].t
            }`
          : item.t[0].t;
      root.querySelector(".field.detail .value").textContent = content;
    } else {
      // For regular nodes and links, display more detailed information (including alerts).
      root = document.querySelector(`.${item.type}.details`);

      if (item.type === "node") {
        root.querySelector(".field.ip .value").textContent = item.t[0].t;

        root.querySelector(".field.type .value").textContent = typeMap[type];

        const os = item.d.type;
        root.querySelector(".field.os .value").textContent = osMap[os] || "-";

        root.querySelector(".field.host .value").textContent = item.d.network;
      } else {
        const src = chart.getItem(item.id1);
        root.querySelector(
          ".field.from .value"
        ).textContent = `${src.t[0].t}:${item.d.port1}`;

        const target = chart.getItem(item.id2);
        root.querySelector(
          ".field.to .value"
        ).textContent = `${target.t[0].t}:${item.d.port2}`;
      }

      root.querySelector(".field.msg .value").innerHTML = `<li>${
        item.d.alert || "None"
      }</li>`;
    }
    root.classList.remove("hide");
  } else {
    document.querySelector(".about.details").classList.remove("hide");
  }
}

// Update foreground state according to what is selected
// If a node: it's the node, its neighbours and the links between
// If a link: it's the link and the nodes at the ends of the link
function updateForeground(id) {
  const toForeground = {};
  const item = chart.getItem(id);
  if (item) {
    toForeground[id] = true;
    if (item.type === "link") {
      toForeground[item.id1] = true;
      toForeground[item.id2] = true;
    } else {
      const neighbours = graph.neighbours(id);
      neighbours.nodes.forEach((neighbourId) => {
        toForeground[neighbourId] = true;
      });
    }

    // Calculate foregrounding based on the nodes - the links will work themselves out.
    chart.foreground((node) => toForeground[node.id]);
  }
}

function getPortGlyph(link, port) {
  const colour = link.d.alert ? colours.alertRed : colours.links;
  return {
    g: [
      {
        t: port,
        c: colour,
        fc: colours.glyphFontColor,
        b: colour,
      },
    ],
  };
}

function showPorts(linkIds) {
  // no link glyphs when using grid arrangements because nodes are too densely
  // packed for the glyphs to be readable
  const withGlyph = getComboShape() === "circle";

  chart.setProperties(
    linkIds.map((id) => {
      const link = chart.getItem(id);
      visiblePorts.push(link);

      return {
        id,
        c: link.d.alert ? colours.alertRed : colours.links,
        t1: withGlyph ? getPortGlyph(link, link.d.port1) : null,
        t2: withGlyph ? getPortGlyph(link, link.d.port2) : null,
        flow: link.d.flow,
      };
    })
  );

  const reveal = alwaysRevealedLinks.concat(linkIds);
  chart.combo().reveal(reveal);
}

function hidePorts() {
  chart.setProperties(
    visiblePorts.map((link) => ({
      id: link.id,
      c: link.d.alert ? colours.alertRed : colours.links,
      t1: null,
      t2: null,
      flow: false,
    }))
  );

  visiblePorts = [];
  chart.combo().reveal(alwaysRevealedLinks);
}

function select(itemId) {
  hidePorts();

  const item = chart.getItem(itemId);
  if (item) {
    updateUI(item);

    if (chart.combo().isCombo(itemId)) {
      chart.foreground(() => true);
    } else {
      if (item.type === "node") {
        showPorts(graph.neighbours(itemId, { all: true }).links);
      } else {
        showPorts([itemId]);
      }
      updateForeground(itemId);
    }
  }
}

async function setComboShape() {
  const shape = getComboShape();
  chart.options({ combos: { shape } });
  chart.setProperties({ id: ".", sh: shape === "circle" ? null : "box" }, true);

  const selection = chart.selection();
  if (selection) {
    select(selection.pop());
  }

  // Arrange all combos from innermost to outmost to size the new shape appropriately and
  // update the arrangement to take account of the new size of any nested combos
  // get list of all combos
  const comboIds = getComboList();
  await chart.combo().close(comboIds);
  await chart.combo().arrange(comboIds, { name: getComboArrangement() });
  chart.layout("organic", { tightness: 6, mode: "adaptive" });
}

async function setSelectedLabels() {
  const selections = chart.selection();
  const props = [];

  const colourLabel = (item, colour) => {
    let labels = item.t;
    if (Array.isArray(labels)) {
      labels = labels.map((label) => ({ ...label, fbc: colour }));
    } else if (typeof labels !== "string") {
      labels = { ...labels, fbc: colour };
    }

    if (item.oc) {
      item.oc = colourLabel(item.oc);
    }

    return { ...item, t: labels };
  };

  chart.each({ type: "node", items: "all" }, (e) => {
    const { id, t, oc } = e;
    const openComboProp = oc ? { oc } : {};
    const colour = selections.includes(id) ? colours.selection : "transparent";

    props.push({ id, ...colourLabel({ t, ...openComboProp }, colour) });
  });

  await chart.setProperties(props);
}

function initialiseInteractions() {
  document
    .getElementById("comboShape")
    .addEventListener("change", setComboShape);
  // Override default combo open behaviour to
  //  a) Track which combos are currently open
  //  b) Use concentric arrangement

  chart.on("double-click", async ({ id: itemId, preventDefault }) => {
    const item = chart.getItem(itemId);
    if (item && item.type === "node" && chart.combo().isCombo(itemId)) {
      if (chart.combo().isOpen(itemId)) {
        delete openCombos[itemId];
        await chart.combo().close(itemId);

        // only run layout if top-level combo is closed
        if (!item.parentId) {
          await chart.layout("organic", {
            mode: "adaptive",
            tightness: 6,
            consistent: true,
          });
        }
      } else {
        openCombos[itemId] = true;
        chart.combo().open(itemId);
      }
    }
    // disable default doubleclick behaviour
    preventDefault();
  });

  // reveal, foreground and show details for the selected item
  chart.on("selection-change", () => {
    const selections = chart.selection();
    setSelectedLabels();

    if (selections.length) {
      select(selections.pop());
    } else {
      hidePorts();
      chart.foreground(() => true);
      updateUI(null);
    }
  });

  // disable marquee dragging
  chart.on("drag-start", ({ type, preventDefault }) => {
    if (type === "marquee") {
      preventDefault();
    }
  });
}

function pingItem(ids) {
  return chart.ping(ids, { c: colours.alertRed, time: 400 });
}

// returns an array of all the combos that contain a node, starting from the innermost
function ancestors(node) {
  const result = [];
  let nodeId = node.id;
  while (nodeId) {
    const parent = chart.combo().find(nodeId, { parent: "first" });
    if (parent) {
      result.push(parent);
      nodeId = parent;
    } else {
      nodeId = null;
    }
  }
  return result;
}

function zoom(ids) {
  function firstZoomableParent(nodeId) {
    // Get an array of all the node's ancestor combos, starting with the outermost.
    const inwardsCombos = ancestors(chart.getItem(nodeId)).reverse();
    // Find the first that is closed.
    const outermostClosedCombo = inwardsCombos.find(
      (ancestor) => !chart.combo().isOpen(ancestor)
    );
    // If our node has no closed ancestor combos, we can zoom to it directly.
    return outermostClosedCombo || nodeId;
  }
  // We can't zoom to a node if it is within a closed combo.
  // So instead we'll zoom to the outermost of its containing closed parent combos.
  const zoomTargets = ids.map(firstZoomableParent);
  return chart.zoom("fit", { animate: true, time: 800, ids: zoomTargets });
}

// Check whether one or more items is visible within the viewport.
function isVisible(itemIds) {
  if (Array.isArray(itemIds)) {
    return itemIds.every(isVisible);
  }

  const item = chart.getItem(itemIds);
  const view = chart.viewOptions();

  // get the view's bounds in world coordinates (minus an offset)
  const topLeft = chart.worldCoordinates(20, 20);
  const bottomRight = chart.worldCoordinates(view.width - 20, view.height - 20);

  // Check whether the item is within that area
  return (
    item.x >= topLeft.x &&
    item.x <= bottomRight.x &&
    item.y >= topLeft.y &&
    item.y <= bottomRight.y
  );
}

// Reveal, zoom and highlight a particular item
// This 'unwraps' combos layer by layer to show the items inside
async function showAlert(itemId) {
  const layers = []; // unwrap combos one layer at a time
  const toOpen = {};

  // Add any parenting combos for an item to the list of combos to unwrap
  function addLayers(node) {
    ancestors(node).forEach((ancestor, idx) => {
      if (!layers[idx]) {
        layers[idx] = [];
      }
      layers[idx].push(ancestor);
      openCombos[ancestor] = true;
      toOpen[ancestor] = true;
    });
  }

  function arrange(comboids) {
    // Open combos have already been arranged (prior to opening).
    const closedComboIds = comboids.filter(
      (comboId) => !chart.combo().isOpen(comboId)
    );
    return Promise.all(
      closedComboIds.map((comboid) =>
        chart
          .combo()
          .arrange(comboid, { animate: false, name: getComboArrangement() })
      )
    );
  }

  // the item ids that we will zoom to fit in the viewport
  let zoomTo = [];
  const item = chart.getItem(itemId);

  async function done() {
    await chart.layout("organic", {
      mode: "adaptive",
      tightness: 6,
      consistent: true,
      fit: false,
      time: 400,
    });
    await zoom(zoomTo);

    if (item.type === "link") {
      return pingItem([item.id, item.id1, item.id2]);
    }
    return pingItem([item.id]);
  }

  // Unwrap one layer of combos (i.e. combos at the same depths)
  async function unwrapLayer() {
    updateForeground(itemId);

    // Get the current topmost layer
    const ids = layers.pop();

    await arrange(ids);
    await chart.combo().open(ids, { time: 800 });
    if (layers.length) {
      return unwrapLayer();
    }
    return done();
  }

  // for a link, we will unwrap and zoom to the nodes at either end
  if (item.type === "link") {
    addLayers(chart.getItem(item.id1));
    addLayers(chart.getItem(item.id2));
    zoomTo = [item.id1, item.id2];
    // for a node, we just unwrap and zoom to the node itself
  } else {
    addLayers(item);
    zoomTo = [itemId];
  }

  select(itemId);

  // Close any combos which we don't need open and clear selection
  const close = [];
  chart.selection([]);

  Object.keys(openCombos).forEach((id) => {
    if (!toOpen[id]) {
      delete openCombos[id];
      close.push(id);
    }
  });
  await chart.combo().close(close, { time: 800 });
  if (close.length > 0) {
    await chart.layout("organic", {
      tightness: 6,
      mode: "adaptive",
      fit: false,
    });
  }

  // Decide whether to zoom to the item before unwrapping the combos
  if (!isVisible(zoomTo)) {
    await zoom([itemId]);
  }
  return unwrapLayer();
}

// Populate the right-hand panel with alert information
function generateAlert(item) {
  const alertsList = document.querySelector(".alerts");
  const button = document.createElement("button");
  button.innerHTML = item.d.alert;
  button.setAttribute("value", item.d.alert);
  button.addEventListener(
    "click",
    async () => {
      const buttons = Array.from(alertsList.children);
      // disable all the buttons during the animation
      buttons.forEach((btn) => {
        btn.disabled = true;
      });
      // run the animation
      await showAlert(item.id);
      // animation is now finished - enable the buttons again
      buttons.forEach((btn) => {
        btn.disabled = false;
      });
    },
    false
  );

  const li = document.createElement("li");
  li.appendChild(button);
  alertsList.appendChild(button);
}

function populateAlertList() {
  data.items.forEach((item) => {
    if (item.d && item.d.alert) {
      generateAlert(item);
    }
  });
}

async function startKeyLines() {
  const options = {
    drag: {
      links: false,
    },
    logo: { u: "/images/Logo.png" },
    selectionColour: colours.selection,
    selectionFontColour: colours.selectionFontColor,
    iconFontFamily: "Font Awesome 5 Free",
    handMode: true,
    imageAlignment: {
      "fas fa-print": { e: 0.8 },
      "fas fa-laptop": { e: 0.75 },
      "fas fa-phone": { e: 0.8 },
      "fas fa-server": { e: 0.8 },
      "fas fa-sitemap": { e: 0.75, dy: -8 },
    },
    defaultStyles: {
      comboGlyph: null,
    },
    gradient: {
      stops: [
        { c: colours.backgroundLight, r: 0 },
        { c: colours.backgroundDark, r: 1.0 },
      ],
    },
  };

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

  // Use a graph engine to track relations in the underlying data
  graph = KeyLines.getGraphEngine();
  graph.load(data);

  chart.load(data);
  await setComboShape();
  // reveal the links with alerts attached and make sure they stay that way
  alwaysRevealedLinks = getAlertLinks();
  chart.combo().reveal(alwaysRevealedLinks);

  // also style them dramatically...
  decorateAlertLinks(alwaysRevealedLinks);
  chart.layout("organic", { tightness: 6, consistent: true });

  updateStyling();
}

async function updateStyling() {
  const getOuterLabels = (place, ip) => [
    {
      t: place,
      position: "s",
      fbc: colours.labelBackground,
      borderRadius: 20,
      bw: 0,
      padding: "3 15 11 15",
      minWidth: 50,
    },
    {
      t: ip,
      fbc: "rgba(0,0,0,0)",
      fs: 8,
      position: "s",
      margin: { top: 18 },
    },
  ];

  const getInnerLabel = (ip) => ({
    t: ip,
    position: "s",
    fbc: colours.labelBackground,
    borderRadius: 20,
    bw: 0,
    padding: "3 5 1 5",
  });

  const props = [];
  chart.each({ type: "node", items: "all" }, ({ id }) => {
    const isCombo = chart.combo().isCombo(id);
    const isOuter = chart.combo().find(id) === null;
    const item = chart.getItem(id);
    if (!isCombo) {
      item.t[0].borderRadius = 20;
      props.push({ id, t: item.t });
      return;
    }

    if (isOuter) {
      const placeName = item.t[0].t;
      const ipAddr = item.t[1].t;
      props.push({
        id,
        t: getOuterLabels(placeName, ipAddr),
        oc: {
          ...item.oc,
          t: getOuterLabels(placeName, ipAddr),
        },
      });
    } else {
      const ip = item.t[0].t;
      props.push({
        id,
        t: getInnerLabel(ip),
        oc: {
          ...item.oc,
          t: getInnerLabel(ip),
        },
      });
    }
  });

  await chart.setProperties(props);
}

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>Network Alerts</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="/networkalerts.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="/networkalerts.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%;">Network Alerts</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">
                <p>
                  Double-click on a combo to see its contents, or select an alert from the list
                  below to reveal it in the chart.
                  
                </p>
              </div>
              <div class="cicontent">
                <fieldset id="comboShape">
                  <legend>Combo shape</legend>
                  <label>
                    <input type="radio" name="shape" value="circle" checked><span>Circular</span>
                  </label>
                  <label class="radio inline">
                    <input type="radio" name="shape" value="rectangle"><span>Rectangular</span>
                  </label>
                </fieldset>
                <fieldset id="alerts">
                  <legend>Alerts found</legend>
                </fieldset>
                <ul class="alerts"></ul>
                <fieldset>
                  <legend class="nav"><span>Details</span></legend>
                </fieldset>
                <div class="about details">
                  <p>Click on a node to display more details</p>
                </div>
                <div class="combo details hide">
                  <div class="field combo-type">
                    <label> </label>
                  </div>
                  <div class="field detail"><span class="value"></span></div>
                </div>
                <div class="node details hide">
                  <div class="field">
                    <label>Device</label>
                  </div>
                  <div class="field type">
                    <label>Type</label><span class="value"></span>
                  </div>
                  <div class="field ip">
                    <label>IP</label><span class="value"></span>
                  </div>
                  <div class="field host">
                    <label>Network</label><span class="value"></span>
                  </div>
                  <div class="field os">
                    <label>OS</label><span class="value"></span>
                  </div>
                  <div class="field msg">
                    <label>Alerts</label>
                    <ul class="value"></ul>
                  </div>
                </div>
                <div class="link details hide">
                  <div class="field">
                    <label>Connection</label>
                  </div>
                  <div class="field from">
                    <label>From</label><span class="value"></span>
                  </div>
                  <div class="field to">
                    <label>To</label><span class="value"></span>
                  </div>
                  <div class="field msg">
                    <label>Alerts</label>
                    <ul class="value"></ul>
                  </div>
                </div>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
.details label {
  font-weight: bold;
  min-width: 70px;
  display: inline-block;
}

form label:first-child {
  margin-top: 0px;
}

.details ul.value {
  margin-left: 20px;
  margin-top: 5px;
  margin-bottom: 5px;
}

.alerts {
  margin-left: 0;
  padding: 0px;
}

.alerts button {
  margin: 6px;
  border: 1px solid transparent;
}

.field.combo-type label {
  text-transform: capitalize;
}
Loading source