//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Visualise hierarchical data with angled links.
import { data, linkColour, primaryColour } from "./angledlinks-data.js";
import KeyLines from "keylines";
const fitButton = document.querySelector("#fit");
const fitSelectionButton = document.querySelector("#fit-selection");
const secondaryChartContainer = document.querySelector("#klchart-secondary");
const secondaryChartResizeObserver = new ResizeObserver(() => {
secondaryChart.zoom("fit");
});
secondaryChartContainer.addEventListener("transitionend", () => {
secondaryChartResizeObserver.unobserve(secondaryChartContainer);
});
const accentColour = "#0F6BE9";
const selectionColour = "#F4D03F";
const initialSelection = ["Company 46", "Company 19"];
const commonOptions = {
handMode: true,
overview: false,
navigation: false,
iconFontFamily: "Font Awesome 5 Free",
selectionColour: selectionColour,
imageAlignment: {
"fas fa-building": { e: 0.9 },
"fas fa-store": { e: 0.85 },
"fas fa-industry": { e: 0.85 },
"fas fa-warehouse": { e: 0.75, dy: -4 },
},
};
let primaryChart;
let secondaryChart;
let highlightedPrimaryPath = undefined;
// Stores the id of the selected node being dragged so we can prevent multi node dragging
let primaryNodeBeingDragged = null;
function setPrimarySelection(ids, animate = true) {
primaryChart.selection(ids);
return onSelectionChange(animate);
}
function onPrimaryChartClick({ id, preventDefault }) {
preventDefault();
const validatedItem = primaryChart.getItem(id);
const currentSelection = primaryChart.selection();
if (validatedItem && validatedItem.type === "node") {
setPrimarySelection([currentSelection[currentSelection.length - 1], id]);
} else if (id === null || validatedItem) {
setPrimarySelection([]);
}
}
async function loadPathIntoSecondaryChart(ids, animate) {
if (animate) {
secondaryChartContainer.style.removeProperty("transition");
secondaryChartResizeObserver.observe(secondaryChartContainer);
} else {
secondaryChartContainer.style.transition = "none";
}
if (ids.length === 0) {
secondaryChartContainer.style.width = "0px";
} else {
const items = ids.map((id) => {
return primaryChart.getItem(id);
});
await secondaryChart.load({
type: "LinkChart",
items,
});
secondaryChart.selection(primaryChart.selection());
await secondaryChart.layout("sequential", {
animate: false,
linkShape: "curved",
});
secondaryChartContainer.style.width = "25%";
if (!animate) {
requestAnimationFrame(() => {
secondaryChart.zoom("fit");
});
}
}
}
async function highlightPrimaryPath(ids) {
if (highlightedPrimaryPath) {
primaryChart.setProperties(
highlightedPrimaryPath.map((id) => {
const type = primaryChart.getItem(id).type;
return {
id,
priority: 0,
c: type === "node" ? primaryColour : linkColour,
};
})
);
highlightedPrimaryPath = undefined;
}
if (ids.length > 0) {
highlightedPrimaryPath = ids;
primaryChart.setProperties(
ids.map((id) => ({ id, priority: 1, c: accentColour }))
);
}
}
function shortestPath() {
const selectedNodes = primaryChart.selection();
return selectedNodes.length === 2
? primaryChart
.graph()
.shortestPaths(selectedNodes[0], selectedNodes[1], { direction: "any" })
.onePath
: [];
}
function onSelectionChange(animate) {
const path = shortestPath();
highlightPrimaryPath(path);
return loadPathIntoSecondaryChart(path, animate);
}
function onPrimaryDragStart({ preventDefault, id, type }) {
const selectedNodes = primaryChart.selection();
// Prevent multi node dragging when dragging one of the two selected nodes
if (
type === "node" &&
selectedNodes.includes(id) &&
selectedNodes.length > 1
) {
preventDefault();
// Enable single node dragging via 'drag-move'
primaryNodeBeingDragged = primaryChart.getItem(id);
}
}
// To avoid KeyLines' multi node dragging behaviour when one of the two selected nodes is being dragged,
// handle the drag manually by updating the node's position via setProperties
function onPrimaryDragMove({ x, y }) {
if (primaryNodeBeingDragged !== null) {
const worldDragCoords = primaryChart.worldCoordinates(x, y);
// Update node position properties to simulate the drag
primaryChart.setProperties({
id: primaryNodeBeingDragged.id,
x: worldDragCoords.x,
y: worldDragCoords.y,
});
}
}
async function loadPrimaryChart() {
primaryChart = await KeyLines.create({
container: "klchart",
options: {
...commonOptions,
backColour: "#1B1D20",
selectedNode: {
ha0: {
c: selectionColour,
r: 30,
w: 4,
},
},
selectedLink: {},
},
});
await primaryChart.load(data);
await primaryChart.layout("sequential", {
linkShape: "angled",
animate: false,
});
primaryChart.on("click", onPrimaryChartClick);
// Drag handlers for bypassing KeyLines' multi node selection dragging behaviour
primaryChart.on("drag-start", onPrimaryDragStart);
primaryChart.on("drag-move", onPrimaryDragMove);
primaryChart.on("drag-end", () => {
if (primaryNodeBeingDragged) {
// Clear the node being dragged when the drag is complete
primaryNodeBeingDragged = null;
}
});
}
async function loadSecondaryChart() {
secondaryChart = await KeyLines.create({
container: "klchart-secondary",
options: {
...commonOptions,
backColour: "#131517",
zoom: {
adaptiveStyling: false,
},
},
});
await loadPathIntoSecondaryChart([]);
secondaryChart.on("click", ({ preventDefault }) => preventDefault());
secondaryChart.on("drag-start", ({ type, preventDefault }) => {
if (type === "node") {
preventDefault();
}
});
}
async function startKeyLines() {
await Promise.all([loadPrimaryChart(), loadSecondaryChart()]);
await setPrimarySelection(initialSelection, false);
requestAnimationFrame(() => {
primaryChart.zoom("fit");
});
fitSelectionButton.addEventListener("click", () => {
primaryChart.zoom("fit", { ids: shortestPath(), animate: true, time: 150 });
});
fitButton.addEventListener("click", () => {
primaryChart.zoom("fit", { animate: true, time: 150 });
});
}
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>Path Analysis in Trees</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="/angledlinks.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="/angledlinks.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 id="charts">
<div class="klchart" id="klchart">
<div id="primary-chart-controls">
<button id="fit"> <img src="/im/up-right-and-down-left-from-center.svg" alt="Fit"></button>
<button id="fit-selection"><img src="/im/location-crosshairs.svg" alt="Fit to selection"></button>
</div>
</div>
<div class="klchart" id="klchart-secondary"></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%;">Path Analysis in Trees</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">
<fieldset>
<p>Angled links help visualise complex hierarchical data, while multiple charts can be used alongside each other to summarise specific information:</p>
<ul>
<li>
Click two nodes in the main chart to highlight the shortest path between them.
This path is summarised in the second chart, to the right.
</li>
<li>Click the main chart to deselect everything.</li>
</ul>
<p>
Use the controls at the top left to either see the whole main chart, or just its selected nodes.
</p>
</fieldset>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
#charts {
display: flex;
width: 100%;
height: 100%;
}
#klchart {
background-color: #1B1D1F;
}
#klchart-secondary {
width: 0px;
transition: width 0.25s;
display: flex;
align-items: center;
justify-content: center;
}
#primary-chart-controls {
background-color: #242528;
display: flex;
position: absolute;
left: 10px;
top: 10px;
padding: 10px;
gap: 10px;
border-radius: 6px;
}
#primary-chart-controls > button {
background-color: #34373e;
padding: 10px;
border-radius: 6px !important;
}
#primary-chart-controls > button:hover {
background-color: #0f6be9;
}
Loading source
