//
// 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">⇧</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
