//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//!    Investigate social structures and relations in complex email data.
import KeyLines from "keylines";
import { data, colours } from "./enron-data.js";

let chart;
let miniChart;
let restoreIds = [];

// Track UI state
const state = {
  sizeBy: "same",
  volume: "off",
  direction: "any",
};

const colourMap = {};

/**
 * debounce - This function delays execution of the passed "fn" until "timeToWait" milliseconds
 * have passed since the last time it was called.  This ensures that the function
 * runs at the end of a particular action to keep performance high.
 */
function debounce(fn, timeToWait = 100) {
  let timeoutId;
  return function debouncedFn(...args) {
    const timeoutFn = () => {
      timeoutId = undefined;
      fn.apply(this, args);
    };
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
    }
    timeoutId = setTimeout(timeoutFn, timeToWait);
  };
}

// ensure that this doesn't get called too often when dragging the selection
// marquee
const loadMiniChart = debounce((items) => {
  miniChart.load({
    type: "LinkChart",
    items,
  });
  miniChart.layout("organic", { consistent: true });
});

function isLink(id) {
  return id.match("-");
}

function updateHighlight(itemIds) {
  const props = [];

  // Remove previous styles
  if (restoreIds) {
    props.push(
      ...restoreIds.map((id) => {
        const colour =
          colourMap[id] || (isLink(id) ? colours.link : colours.node);
        return {
          id,
          c: colour,
          b: colour,
          ha0: null,
        };
      })
    );
    restoreIds = [];
  }

  // Add new styles
  if (itemIds.length) {
    // Find the neighbours of the provided items
    const toHighlight = [];
    itemIds.forEach((id) => {
      if (isLink(id)) {
        const link = chart.getItem(id);
        toHighlight.push(id, link.id1, link.id2);
      } else {
        const neighbours = chart.graph().neighbours(id);
        toHighlight.push(id, ...neighbours.nodes, ...neighbours.links);
      }
    });

    // For each neighbouring item, add some styling
    toHighlight.forEach((id) => {
      // Cache the existing styles
      restoreIds.push(id);

      // Generate new styles
      const style = {
        id,
      };
      if (isLink(id)) {
        // For links, just set the colour
        style.c = colours.selected;
      } else {
        // For nodes, add a halo
        style.ha0 = {
          c: colours.selected,
          r: 34,
          w: 6,
        };
      }
      props.push(style);
    });
  }

  chart.setProperties(props);
}

async function runLayout(inconsistent, mode) {
  const packing = mode === "adaptive" ? "adaptive" : "circle";
  return chart.layout("organic", {
    time: 500,
    tightness: 4,
    consistent: !inconsistent,
    packing,
    mode,
  });
}

function klReady(charts) {
  [chart, miniChart] = charts;

  chart.load(data);
  chart.zoom("fit", { animate: false }).then(runLayout);

  // On selection change:
  //   1) add styles to the targeted and neighbouring items
  //   2) copy the selected item and its neighbours to the miniChart
  chart.on("selection-change", () => {
    const ids = chart.selection();

    updateHighlight(ids);

    const miniChartItems = [];

    if (ids.length > 0) {
      const { nodes, links } = chart.graph().neighbours(ids);

      chart.each({ type: "node" }, (node) => {
        if (ids.includes(node.id) || nodes.includes(node.id)) {
          // Clear hover styling and position
          node.x = 0;
          node.y = 0;
          delete node.ha0;
          miniChartItems.push(node);
        }
      });

      chart.each({ type: "link" }, (link) => {
        if (ids.includes(link.id) || links.includes(link.id)) {
          link.c = colours.link;
          miniChartItems.push(link);
        }
      });
    }
    loadMiniChart(miniChartItems);
  });
}

// this function picks a colour from a range of colours based on the value
function colourPicker(value) {
  const { bands, node } = colours;
  if (value > 0.75) {
    return bands[2];
  }
  if (value > 0.5) {
    return bands[1];
  }
  if (value > 0.25) {
    return bands[0];
  }
  return node;
}

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

  return (value - min) / (max - min);
}

function miniChartFilter(items) {
  return items.filter(({ id }) => miniChart.getItem(id));
}

async function animateValues(values, links) {
  const valuesArray = Object.values(values);

  const max = Math.max(...valuesArray);
  const min = Math.min(...valuesArray);

  const items = Object.entries(values)
    .map(([id, value]) => {
      // Normalize the value in the range 0 -> 1
      const normalized = normalize(max, min, value);

      // enlarge nodes with higher values
      const e = Math.max(1, normalized * 5);

      // Choose a colour (use bands if there is a range of values)
      const c = max !== min ? colourPicker(normalized) : colours.node;
      colourMap[id] = c;

      return { id, e, c, b: c };
    })
    .concat(links);

  const miniItems = miniChartFilter(items);

  // Update the main chart and miniChart concurrently
  return Promise.all([
    chart
      .animateProperties(items, { time: 500 })
      .then(() => runLayout(undefined, "adaptive")),
    miniChart
      .animateProperties(miniItems, { time: 500 })
      .then(() => miniChart.layout("organic", { consistent: true })),
  ]);
}

function same() {
  return new Promise((resolve) => {
    const sizes = {};
    chart.each({ type: "node" }, (node) => {
      sizes[node.id] = 0;
    });
    resolve(sizes);
  });
}

function wrapCallback(fn) {
  return (options) => new Promise((resolve) => resolve(fn(options)));
}

function getAnalysisFunction(name) {
  if (name.match(/^(degrees|pageRank|eigenCentrality)$/)) {
    return wrapCallback(chart.graph()[name]);
  }
  if (name.match(/^(closeness|betweenness)$/)) {
    return chart.graph()[name];
  }
  return same;
}

async function analyseChart() {
  const { sizeBy, volume } = state;

  const options = {};
  // Configure weighting
  if (volume === "on") {
    if (sizeBy.match(/^(betweenness|closeness)$/)) {
      options.weights = true;
    }
    options.value = "count";
  }
  // Configure direction options
  if (sizeBy.match(/^(betweenness|pageRank)$/)) {
    options.directed = state.direction !== "any";
  } else {
    options.direction = state.direction;
  }

  const analyse = getAnalysisFunction(sizeBy);
  const values = await analyse(options);
  const linkWidths = calculateLinkWidths(volume === "on");
  return animateValues(values, linkWidths);
}

function calculateLinkWidths(showValue) {
  const links = [];
  chart.each({ type: "link" }, (link) => {
    const linkcount = link.d.count;
    let width = 1;
    if (showValue) {
      if (linkcount > 300) {
        width = 36;
      } else if (linkcount > 200) {
        width = 27;
      } else if (linkcount > 100) {
        width = 18;
      } else if (linkcount > 50) {
        width = 9;
      }
    }
    links.push({ id: link.id, w: width });
  });
  return links;
}

function doZoom(name) {
  chart.zoom(name, { animate: true, time: 350 });
}

function registerClickHandler(id, fn) {
  document.getElementById(id).addEventListener("click", fn);
}

function updateActiveState(nodes, activeValue) {
  nodes.forEach((node) => {
    if (node.value === activeValue) {
      node.classList.add("active");
    } else {
      node.classList.remove("active");
    }
  });
}

function registerButtonGroup(className, handler) {
  const nodes = document.querySelectorAll(`.${className}`);
  nodes.forEach((node) => {
    node.addEventListener("click", () => {
      const { value } = node;
      updateActiveState(nodes, value);

      handler(value);
    });
  });
}

function initUI() {
  // Chart overlay
  registerClickHandler("home", () => {
    doZoom("fit");
  });
  registerClickHandler("zoomIn", () => {
    doZoom("in");
  });
  registerClickHandler("zoomOut", () => {
    doZoom("out");
  });
  registerClickHandler("changeMode", () => {
    const hand = !!chart.options().handMode; // be careful with undefined
    chart.options({ handMode: !hand });

    const icon = document.getElementById("iconMode");
    icon.classList.toggle("fa-arrows-alt");
    icon.classList.toggle("fa-edit");
  });
  registerClickHandler("layout", () => {
    runLayout(true, "full");
  });

  // Right hand menu
  registerButtonGroup("volume", (volume) => {
    state.volume = volume;
    analyseChart();
  });

  registerButtonGroup("size", (sizeBy) => {
    state.sizeBy = sizeBy;
    analyseChart();
  });

  registerButtonGroup("direction", (direction) => {
    state.direction = direction;
    analyseChart();
  });
}

async function loadKeyLines() {
  initUI();

  const baseOpts = {
    arrows: "normal",
    handMode: true,
    navigation: { shown: false },
    overview: { icon: false },
    selectedNode: {
      c: colours.selected,
      b: colours.selected,
      fbc: colours.selected,
    },
    selectedLink: {
      c: colours.selected,
    },
  };

  const mainChartConfig = {
    container: "klchart",
    options: Object.assign({}, baseOpts, {
      drag: {
        links: false,
      },
      logo: { u: "/images/Logo.png" },
    }),
  };

  const miniChartConfig = {
    container: "minikl",
    options: baseOpts,
  };

  const charts = await KeyLines.create([mainChartConfig, miniChartConfig]);
  klReady(charts);
}

window.addEventListener("DOMContentLoaded", loadKeyLines);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
  <head>
    <meta charset="UTF-8">
    <title>Social Network Analysis</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/font-awesome.css">
    <link rel="stylesheet" type="text/css" href="/enron.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="/enron.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 class="controloverlay">
              <ul>
                <li><a id="home" rel="tooltip" title="Home"><i class="fa fa-home"></i></a></li>
                <li><a id="layout" rel="tooltip" title="Layout chart"><i class="fa fa-random"></i></a></li>
                <li><a id="changeMode" rel="tooltip" title="Drag mode"><i class="fa fa-arrows-alt" id="iconMode"></i></a></li>
                <li><a id="zoomIn" rel="tooltip" title="Zoom in"><i class="fa fa-plus-square"></i></a></li>
                <li><a id="zoomOut" rel="tooltip" title="Zoom out"><i class="fa fa-minus-square"></i></a></li>
              </ul>
            </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%;">Social Network Analysis</text>
          </svg>
        </div>
        <div class="tab-content-panel" data-tab-group="rhs">
          <div class="toggle-content is-visible tabcontent" id="controlsTab">
            <p>Click a node to see its neighbours. Select size strategy below.</p>
            <form autocomplete="off" onsubmit="return false" id="rhsForm">
              <div id="miniParent">
                <div id="miniContainer">
                  <div id="minikl"></div>
                </div>
              </div>
              <div id="analysisParent"> 
                <div class="cicontent" id="analysisBox" style="margin-bottom:10px">
                  <div class="form-inline">
                    <div class="btn-row">
                      <div class="pull-left" style="width: fit-content; height: 0px;">
                        <p>Email Volumes:</p>
                      </div>
                      <div class="btn-group pull-right">
                        <button class="volume btn pull-left active" type="button" value="off" id="volumeOff">Off</button>
                        <button class="volume btn pull-left" type="button" value="on" id="volumeOn">On</button>
                      </div>
                    </div>
                    <div class="btn-row" style="margin-top:10px;">
                      <div class="pull-left">
                        <p>Size:</p>
                      </div>
                      <div class="btn-group size-btn pull-right">
                        <button class="size btn btn-kl pull-left active" type="button" value="same" id="same">Same</button>
                        <button class="size btn pull-left" type="button" value="degrees" id="degree">Degree</button>
                        <button class="size btn pull-left" type="button" value="closeness" id="closeness">Closeness</button>
                        <button class="size btn pull-left" type="button" value="betweenness" id="betweenness">Betweenness</button>
                        <button class="size btn pull-left" type="button" value="pageRank" id="pagerank">PageRank</button>
                        <button class="size btn pull-left" type="button" value="eigenCentrality" id="eigenvector">EigenCentrality</button>
                      </div>
                    </div>
                    <div class="btn-row" id="analysis-control">
                      <div class="pull-left">
                        <p>Analyse:</p>
                      </div>
                      <div class="btn-group pull-right">
                        <button class="direction btn pull-left" type="button" value="from" id="sending">Sent</button>
                        <button class="direction btn pull-left" type="button" value="to" id="receiving">Received</button>
                        <button class="direction btn btn-kl pull-left active" type="button" value="any" id="any">All</button>
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
#miniContainer {
  background-color: #fff;
  margin: 10px;
}
#miniContainer canvas {
  margin: 0 auto;
}
#minikl {
  height: 232px;
}
.controloverlay {
  position: absolute;
  left: 12px;
  top: 10px;
  padding: 0;
  margin: 0;
  font-size: 28px;
  z-index: 9001;

  background-color: rgba(250, 250, 250, 0.8);

  border: solid 1px #ededed;
  box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1);
}

.controloverlay ul {
  list-style-type: none;
  margin: 0;
  padding: 6px;
}

.controloverlay li {
  margin-bottom: 3px;
  text-align: center;
  font-size: 24px;
}

.controloverlay li:last-child {
  margin-bottom: 0;
}

.controloverlay a i {
  color: #2d383f;
  text-decoration: none;
  cursor: pointer;
}

.btn-row {
  overflow: auto;
  clear: both;
}
.size-btn button.btn,
.size-btn button.btn.active {
  min-width: 80px;
  margin-top: 4px;
  margin-left: -4px;
}

.btn-group.size-btn {
  width: 100%;
}

.btn-group button.size {
  width: calc((100% / 3) + 1.5px);
}

#analysis-control {
  margin-top:10px;
  width: 100%;
}

#analysis-control .pull-left {
  width: fit-content;
  height: 0px;
}

#analysis-control .pull-right button {
  height: 36px;
}


@media (max-width: 1023px) {
  .btn-group button.size {
    width: calc((100% / 2) + 2px);
  }
}

@media (max-width: 767px) {
  .btn-group.size-btn button.size.btn {
    width: calc(100% - 60px);
    margin-left: 30px;
    margin-right: 30px;
  }
  #analysis-control { width: calc(100% + 8px); }
  #analysis-control .pull-left {
    height: 30px;
  }
  
}
Loading source