//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Interact with KeyLines using just the keyboard.
import KeyLines from "keylines";
let chart;
let canvas;
let focusPool;
let labelsToIds;
let focusId = null;
let focusIndex = 0;
const searchDiv = document.getElementById("searchDiv");
const searchInput = document.getElementById("searchInput");
const containerDiv = document.getElementById("fullscreen") || document.body;

const KEYCODES = {
  TAB: 9,
  ENTER: 13,
  LARROW: 37,
  UARROW: 38,
  RARROW: 39,
  DARROW: 40,
  ZERO: 48,
  FF_PLUS: 61,
  Q: 81,
  FF_MINUS: 173,
  PLUS: 187,
  MINUS: 189,
  PERIOD: 190,
  FSLASH: 191,
};

const debounce = (fn, timeout = 100) => {
  let id;
  return (args) => {
    if (id) clearTimeout(id);
    id = setTimeout(() => {
      fn(args);
    }, timeout);
  };
};
const wrapAroundArrayIfNeeded = (array, index) => {
  const { length } = array;
  if (index < 0) return length - 1;
  if (index > length - 1) return 0;
  return index;
};
const prevInArray = (array, index) => wrapAroundArrayIfNeeded(array, index - 1);
const nextInArray = (array, index) => wrapAroundArrayIfNeeded(array, index + 1);
const addEventListenerOnce = (domElement, event, handler) => {
  const listener = () => {
    domElement.removeEventListener(event, listener);
    handler();
  };
  domElement.addEventListener(event, listener);
};
const isNode = (item) => item.type === "node";
const isHidden = (node) => !!node.hi;
const isInView = (node) => {
  const { x, y } = chart.viewCoordinates(node.x, node.y);
  const { width, height } = chart.viewOptions();
  return x > 0 && x < width && y > 0 && y < height;
};
const proximityCheck = (direction) => {
  const { zoom, width, height } = chart.viewOptions();
  // At all zoom levels, border is approximately same size as radius of standard sized node
  const border = 20 * zoom + 5;
  return {
    up: (node) => {
      const { y } = chart.viewCoordinates(node.x, node.y);
      return y < border;
    },
    down: (node) => {
      const { y } = chart.viewCoordinates(node.x, node.y);
      return height - y < border;
    },
    left: (node) => {
      const { x } = chart.viewCoordinates(node.x, node.y);
      return x < border;
    },
    right: (node) => {
      const { x } = chart.viewCoordinates(node.x, node.y);
      return width - x < border;
    },
  }[direction];
};
const selectionIsNearEdge = (direction) => {
  const isNearEdge = proximityCheck(direction);
  const selection = chart.selection();
  return selection.some((id) => {
    const item = chart.getItem(id);
    return isNode(item) && isInView(item) && isNearEdge(item);
  });
};
const lastSelectedVisibleNode = () => {
  const selection = chart.selection();
  const nodeId = selection.reverse().find((id) => {
    const item = chart.getItem(id);
    return isNode(item) && isInView(item);
  });
  return chart.getItem(nodeId);
};
const showTooltip = () => {
  const tooltip = document.createElement("div");
  tooltip.id = "tt";
  tooltip.className = "tooltip";
  tooltip.style.display = "block";
  const { top, left } = searchInput.getBoundingClientRect();
  tooltip.style.top = `${Math.round(top) - 22}px`;
  tooltip.style.left = `${Math.round(left)}px`;
  tooltip.innerHTML = "To return to the chart press the TAB key";
  containerDiv.appendChild(tooltip);
};
const hideTooltip = () => {
  const tooltip = document.getElementById("tt");
  if (tooltip) {
    tooltip.remove();
  }
};
const activateItem = (direction) => {
  const contextMenu = document.getElementById("contextMenu");
  if (contextMenu) {
    const activeItem = contextMenu.querySelector("a.highlight");
    if (activeItem) activeItem.classList.remove("highlight");
    const items = [...contextMenu.querySelectorAll("a")];
    const indexOfActive = items.indexOf(activeItem) || 0;
    const index =
      direction === "above"
        ? prevInArray(items, indexOfActive)
        : nextInArray(items, indexOfActive);
    const item = items[index];
    item.classList.add("highlight");
  }
};
const clickActiveItem = () => {
  const contextMenu = document.getElementById("contextMenu");
  if (contextMenu) {
    const activeItem = contextMenu.querySelector("a.highlight");
    if (activeItem) activeItem.click();
  }
};
const navigateContextMenu = ({ keyCode, preventDefault }) => {
  preventDefault();
  switch (keyCode) {
    case KEYCODES.Q:
      hideContextMenu();
      break;
    case KEYCODES.ENTER:
      clickActiveItem();
      break;
    case KEYCODES.UARROW:
      activateItem("above");
      break;
    case KEYCODES.DARROW:
      activateItem("below");
      break;
    default:
      break;
  }
};
const createListItems = (opts) =>
  opts.map(({ id, text, onClick }) => {
    const li = document.createElement("li");
    const a = document.createElement("a");
    a.id = id;
    a.href = "#";
    a.tabIndex = -1;
    a.innerHTML = text;
    a.addEventListener("click", onClick);
    a.addEventListener("contextmenu", (event) => {
      event.preventDefault();
    });
    li.appendChild(a);
    return li;
  });
const getContextMenuPosition = (contextMenu, coords) => {
  const { x, y } = coords;
  const { width, height } = chart.viewOptions();
  const { width: w, height: h } = contextMenu.getBoundingClientRect();
  const { top: offsetY, left: offsetX } = canvas.getBoundingClientRect();
  const [baseY, baseX] = [y + offsetY, x + offsetX];
  const top = baseY + h < height ? baseY : baseY - h;
  const left = baseX + w < width ? baseX : baseX - w;
  return { top, left };
};
const getDefaultPosition = (contextMenu) => {
  const { width, height } = chart.viewOptions();
  const { width: w, height: h } = contextMenu.getBoundingClientRect();
  const top = (height - h) / 2;
  const left = (width - w) / 2;
  return { top, left };
};
const showContextMenu = (listItems, coords) => {
  hideContextMenu();
  const contextMenu = document.createElement("ul");
  contextMenu.id = "contextMenu";
  contextMenu.role = "menu";
  contextMenu.style.display = "block";
  contextMenu.className = "kl-dropdown-menu";
  listItems.forEach((li) => contextMenu.appendChild(li));
  containerDiv.appendChild(contextMenu);
  const { top, left } = coords
    ? getContextMenuPosition(contextMenu, coords)
    : getDefaultPosition(contextMenu);
  contextMenu.style.top = `${Math.round(top)}px`;
  contextMenu.style.left = `${Math.round(left)}px`;
  contextMenu.addEventListener("click", (event) => {
    event.preventDefault();
    hideContextMenu();
  });
  contextMenu.addEventListener("contextmenu", (event) => {
    event.preventDefault();
  });
  chart.off("key-down", navigateChart);
  chart.on("key-down", navigateContextMenu);
};
const hideContextMenu = () => {
  const contextMenu = document.getElementById("contextMenu");
  if (contextMenu) {
    chart.off("key-down", navigateContextMenu);
    chart.on("key-down", navigateChart);
    contextMenu.remove();
    canvas.focus();
  }
};
const clearFocus = () => {
  if (focusId) {
    chart.setProperties({ id: focusId, ha0: null });
  }
  focusId = null;
};
const changeFocus = (shift) => {
  clearFocus();
  focusIndex = shift
    ? prevInArray(focusPool, focusIndex)
    : nextInArray(focusPool, focusIndex);
  focusId = focusPool[focusIndex];
  chart.setProperties({
    id: focusId,
    ha0: { c: "rgba(140, 100, 166, 0.5)", r: 35, w: 15 },
  });
};
const selectFocus = () => {
  const selection = chart.selection();
  chart.selection([...selection, focusId]);
};
const deselectFocus = () => {
  const selection = chart.selection();
  chart.selection(selection.filter((id) => id !== focusId));
};
const focusIsSelected = () => {
  const selection = chart.selection();
  return selection.includes(focusId);
};
const toggleFocusSelection = () => {
  if (focusIsSelected()) {
    deselectFocus();
  } else {
    selectFocus();
  }
};
const isFocusRemovedOrHidden = () => {
  const focus = chart.getItem(focusId);
  return !focus || isHidden(focus);
};
const updateState = () => {
  if (isFocusRemovedOrHidden()) clearFocus();
  focusPool = [];
  focusIndex = 0;
  labelsToIds = new Map();
  chart.each({ type: "node", items: "all" }, (node) => {
    if (!isHidden(node)) {
      const { id, t } = node;
      labelsToIds.set(t, id);
      if (isInView(node)) focusPool.push(id);
    }
  });
};
const createOptions = (labels) =>
  labels.map((label) => {
    const option = document.createElement("option");
    option.value = label;
    return option;
  });
const updateSelectionAndZoomToNode = (label) => {
  const id = labelsToIds.get(label);
  const selection = chart.selection();
  chart.selection([...selection, id]);
  chart.zoom("fit", { animate: true, time: 500, ids: [id] });
};
const updateDatalist = () => {
  let datalist = document.getElementById("employees");
  if (datalist) {
    searchDiv.removeChild(datalist);
    searchInput.removeAttribute("list");
  }
  datalist = document.createElement("datalist");
  datalist.id = "employees";
  const labels = [...labelsToIds.keys()];
  const options = createOptions(labels);
  options.forEach((option) => datalist.appendChild(option));
  searchDiv.appendChild(datalist);
  searchInput.setAttribute("list", datalist.id);
};
const itemOpts = [
  {
    id: "hideSelectedNodes",
    text: "Hide selected nodes",
    onClick: () => {
      const selection = chart.selection();
      const props = [];
      chart.each({ type: "node" }, ({ id }) => {
        if (selection.includes(id)) props.push({ id, hi: true });
      });
      chart.animateProperties(props);
      updateState();
    },
  },
  {
    id: "removeSelectedNodes",
    text: "Remove selected nodes",
    onClick: () => {
      const selection = chart.selection();
      chart.removeItem(selection);
      updateState();
    },
  },
  {
    id: "addNeighbourNodesToSelection",
    text: "Add neighbour nodes to selection",
    onClick: () => {
      const selection = chart.selection();
      const neighbours = chart.graph().neighbours(selection);
      chart.selection([...selection, ...neighbours.nodes]);
    },
  },
];
const backgroundOpts = [
  {
    id: "showAll",
    text: "Show all hidden nodes",
    onClick: () => {
      chart.filter(() => true);
      updateState();
    },
  },
];
const panIfSelectionIsNearEdge = (direction) => {
  if (selectionIsNearEdge(direction)) {
    chart.pan(direction);
  }
};
const clearSelectionOrFocus = () => {
  const selection = chart.selection();
  const { length } = selection;
  if (length > 0) chart.selection([]);
  else clearFocus();
};
const enableSearch = () => {
  addEventListenerOnce(document.body, "keyup", () => {
    searchInput.focus();
  });
};
const switchMode = ({ modifierKeys: { consistentCtrl } }) => {
  if (!consistentCtrl) {
    chart.options({ handMode: false });
  }
};
const buildContextMenu = (node, coords) => {
  const opts = node ? itemOpts : backgroundOpts;
  const listItems = createListItems(opts);
  showContextMenu(listItems, coords);
};
const openContentMenu = () => {
  const node = lastSelectedVisibleNode();
  buildContextMenu(node, node ? chart.viewCoordinates(node.x, node.y) : null);
};
const navigateChart = ({
  keyCode,
  modifierKeys: { shift },
  preventDefault,
}) => {
  switch (keyCode) {
    case KEYCODES.ZERO:
      chart.zoom("fit");
      break;
    case KEYCODES.PLUS:
    case KEYCODES.FF_PLUS:
      chart.zoom("in");
      break;
    case KEYCODES.MINUS:
    case KEYCODES.FF_MINUS:
      chart.zoom("out");
      break;
    case KEYCODES.PERIOD:
      openContentMenu();
      break;
    case KEYCODES.LARROW:
      panIfSelectionIsNearEdge("left");
      break;
    case KEYCODES.UARROW:
      panIfSelectionIsNearEdge("up");
      break;
    case KEYCODES.RARROW:
      panIfSelectionIsNearEdge("right");
      break;
    case KEYCODES.DARROW:
      panIfSelectionIsNearEdge("down");
      break;
    case KEYCODES.Q:
      clearSelectionOrFocus();
      break;
    case KEYCODES.ENTER:
      toggleFocusSelection();
      break;
    case KEYCODES.FSLASH:
      enableSearch();
      preventDefault();
      break;
    case KEYCODES.TAB:
      changeFocus(shift);
      preventDefault();
      break;
    default:
      break;
  }
};
const initialiseView = () => {
  chart.zoom("fit");
  chart.layout("organic");
  chart.selection(["343112"]);
};
const initialiseInteractions = () => {
  canvas.focus();

  updateState();

  searchInput.addEventListener("focusin", (event) => {
    event.preventDefault();
    showTooltip();
    updateDatalist();
  });
  searchInput.addEventListener("focusout", (event) => {
    event.preventDefault();
    hideTooltip();
  });
  searchInput.addEventListener("keydown", (event) => {
    const { TAB, ENTER } = KEYCODES;
    if (event.keyCode === ENTER || event.keyCode === TAB) {
      const label = event.target.value;
      updateSelectionAndZoomToNode(label);
      if (event.keyCode === TAB) {
        event.preventDefault();
        canvas.focus();
      }
    }
  });
  chart.on("key-up", switchMode);
  chart.on("key-down", navigateChart);

  const debouncedUpdateState = debounce(updateState);
  const disableLinkSelection = ({ id, preventDefault }) => {
    const item = chart.getItem(id);
    if (item && !isNode(item)) preventDefault();
  };
  const contextMenuHandler = ({ id, x, y, preventDefault }) => {
    const item = chart.getItem(id);
    if (item) {
      if (isNode(item)) {
        buildContextMenu(item, { x, y });
      } else {
        chart.selection([]);
        preventDefault();
      }
    } else {
      chart.selection([]);
      buildContextMenu(null, { x, y });
    }
  };
  chart.on("click", hideContextMenu);
  chart.on("click", disableLinkSelection);
  chart.on("view-change", hideContextMenu);
  chart.on("view-change", debouncedUpdateState);
  chart.on("context-menu", contextMenuHandler);
};
const startKeyLines = async () => {
  chart = await KeyLines.create({
    id: "kl",
    container: "klchart",
    options: {
      logo: { u: "/images/Logo.png" },
      selectionColour: "rgb(120,100,150)",
    },
  });

  canvas = document.getElementById("kl");

  const data = await fetch("/keyboardshortcuts-data.json").then((res) =>
    res.json()
  );
  chart.load(data);
  initialiseView();
  initialiseInteractions();
};
window.addEventListener("DOMContentLoaded", startKeyLines);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
  <head>
    <meta charset="UTF-8">
    <title>Keyboard Shortcuts</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="/keyboardshortcuts.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="/keyboardshortcuts.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" 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%;">Keyboard Shortcuts</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">
                <div id="searchDiv" style="margin:15px"></div>
                <label class="search-label"><span>Search </span><span class="key inline-key">/</span>
                  <input id="searchInput" type="search" placeholder="Employee" autocomplete="on">
                </label>
                <hr>
                <fieldset>
                  <table id="shortcutList" table-layout="fixed">
                    <tr>
                      <td align="center">
                        <p class="key">0</p>
                      </td>
                      <td>Zoom to fit</td>
                    </tr>
                    <tr>
                      <td align="center">
                        <p class="key">+</p>
                      </td>
                      <td>Zoom in</td>
                    </tr>
                    <tr>
                      <td align="center">
                        <p class="key">-</p>
                      </td>
                      <td>Zoom out</td>
                    </tr>
                    <tr>
                      <td align="center">
                        <p class="key">/</p>
                      </td>
                      <td>Search</td>
                    </tr>
                    <tr>
                      <td align="center">
                        <p class="key">TAB</p>
                      </td>
                      <td>Cycle focus forwards through visible nodes</td>
                    </tr>
                    <tr>
                      <td align="center"><span class="key">&#8679;</span><span> + </span><span class="key">TAB</span></td>
                      <td>Cycle focus backwards through visible nodes</td>
                    </tr>
                    <tr>
                      <td align="center">
                        <p class="key">ENTER</p>
                      </td>
                      <td>Select / deselect focussed node</td>
                    </tr>
                    <tr>
                      <td align="center">
                        <p class="key">.</p>
                      </td>
                      <td>Open context menu</td>
                    </tr>
                    <tr>
                      <td align="center">
                        <p class="key">q</p>
                      </td>
                      <td>Close context menu / Clear selection / Clear focus</td>
                    </tr>
                    <tr>
                      <td align="center">
                        <p class="arrow-keys">Arrows</p>
                      </td>
                      <td>Move selection</td>
                    </tr>
                  </table>
                </fieldset>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>

.key {
  display: inline-block;
  width: auto;
  min-width: 24px;
  color: #5E5E5E;
  font: bold 14px arial;
  text-decoration: none;
  text-align: center;
  margin: 3px auto;
  padding: 3px 6px;
  background: #EFF0F2;
  border-radius: 3px;
  border-top: 1px solid #F5F5F5;
  box-shadow: inset 0 0 20px #E8E8E8, 0 1px 0 #C3C3C3, 0 1px 0 #C9C9C9, 0 1px 2px #333;
  text-shadow: 0px 1px 0px #F5F5F5;
}

.inline-key {
  margin-right: 3px;
  margin-left: 6px;
}

.arrow-keys {
  color: #5E5E5E;
  font: bold 14px arial;
  margin: auto;
  vertical-align: center;
}

#shortcutList {
  margin-bottom: 10px;
}
#shortcutList tr {
  height: 29px;
}

#shortcutList tr > td:first-child {
  width: 97px;
  padding-right: 6px;
  white-space: nowrap;
}
hr {
  margin: 10px 0;
}

#searchInput {
  margin-bottom: 0;
}
.search-label > span {
  vertical-align: sub;
  vertical-align: -webkit-baseline-middle;
}
.search-label * {
  margin-top: -6px
}
.tooltip {
  z-index: 1000;
  padding-left: 4px;
  padding-right: 4px;
  color: white;
  border-radius: 5px;
  background-color: #333;
  position: absolute;
  font-size: 10px;
  text-align: center;
}
/* removes the dropdown arrow from the seachbox */
input::-webkit-calendar-picker-indicator {
  display: none;
}
Loading source