This demo shows how relationships between nodes in a large data hierarchy can be shown effectively, and explored.
Angled links
Angled links are used to reflect the tree-like structure of the data so that relationships can be visualised more clearly.
Multiple charts
A second chart can help you focus in on just one aspect of the main chart. In this case, it extracts and shows just the shortest path between the two selected nodes.
You can use additional charts to highlight complex relationships in the main chart and present them in a different way. This allows you to emphasise additional aspects, while preserving contextual information.
Key functions used:
- chart.layout, especially the linkShape option
- chart.graph().shortestPaths
- chart.setProperties
- chart.on
import { data, linkColour, primaryColour } from "./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); import KeyLines from "keylines";
export const primaryColour = "rgba(220, 220, 220, 0.6)";
export const textColour = "white";
const fontIconColour = "rgba(220, 220, 220, 0.95)";
export const linkColour = fontIconColour;
const linkWidth = 3;
export const icons = ["fas fa-warehouse", "fas fa-store", "fas fa-industry", "fas fa-building"];
const links = [
{
id1: "Company 85",
id2: "Company 74",
},
{
id1: "Company 34",
id2: "Company 49",
},
{
id1: "Company 16",
id2: "Company 51",
},
{
id1: "Company 42",
id2: "Company 36",
},
{
id1: "Company 98",
id2: "Company 67",
},
{
id1: "Company 4",
id2: "Company 48",
},
{
id1: "Company 64",
id2: "Company 1",
},
{
id1: "Company 45",
id2: "Company 50",
},
{
id1: "Company 13",
id2: "Company 23",
},
{
id1: "Company 37",
id2: "Company 39",
},
{
id1: "Company 35",
id2: "Company 83",
},
{
id1: "Company 12",
id2: "Company 27",
},
{
id1: "Company 87",
id2: "Company 38",
},
{
id1: "Company 52",
id2: "Company 79",
},
{
id1: "Company 32",
id2: "Company 53",
},
{
id1: "Company 46",
id2: "Company 76",
},
{
id1: "Company 98",
id2: "Company 93",
},
{
id1: "Company 95",
id2: "Company 45",
},
{
id1: "Company 57",
id2: "Company 75",
},
{
id1: "Company 35",
id2: "Company 62",
},
{
id1: "Company 94",
id2: "Company 84",
},
{
id1: "Company 42",
id2: "Company 90",
},
{
id1: "Company 72",
id2: "Company 70",
},
{
id1: "Company 83",
id2: "Company 60",
},
{
id1: "Company 79",
id2: "Company 32",
},
{
id1: "Company 19",
id2: "Company 59",
},
{
id1: "Company 16",
id2: "Company 100",
},
{
id1: "Company 4",
id2: "Company 81",
},
{
id1: "Company 17",
id2: "Company 20",
},
{
id1: "Company 40",
id2: "Company 95",
},
{
id1: "Company 72",
id2: "Company 56",
},
{
id1: "Company 81",
id2: "Company 46",
},
{
id1: "Company 48",
id2: "Company 28",
},
{
id1: "Company 54",
id2: "Company 6",
},
{
id1: "Company 83",
id2: "Company 69",
},
{
id1: "Company 72",
id2: "Company 98",
},
{
id1: "Company 94",
id2: "Company 87",
},
{
id1: "Company 23",
id2: "Company 37",
},
{
id1: "Company 95",
id2: "Company 17",
},
{
id1: "Company 96",
id2: "Company 44",
},
{
id1: "Company 86",
id2: "Company 57",
},
{
id1: "Company 19",
id2: "Company 26",
},
{
id1: "Company 81",
id2: "Company 65",
},
{
id1: "Company 73",
id2: "Company 99",
},
{
id1: "Company 96",
id2: "Company 43",
},
{
id1: "Company 60",
id2: "Company 54",
},
{
id1: "Company 48",
id2: "Company 94",
},
{
id1: "Company 60",
id2: "Company 68",
},
{
id1: "Company 73",
id2: "Company 41",
},
{
id1: "Company 17",
id2: "Company 18",
},
{
id1: "Company 64",
id2: "Company 19",
},
{
id1: "Company 81",
id2: "Company 13",
},
{
id1: "Company 42",
id2: "Company 91",
},
{
id1: "Company 6",
id2: "Company 72",
},
{
id1: "Company 78",
id2: "Company 15",
},
{
id1: "Company 97",
id2: "Company 78",
},
{
id1: "Company 82",
id2: "Company 35",
},
{
id1: "Company 78",
id2: "Company 92",
},
{
id1: "Company 46",
id2: "Company 82",
},
{
id1: "Company 13",
id2: "Company 47",
},
{
id1: "Company 100",
id2: "Company 7",
},
{
id1: "Company 73",
id2: "Company 4",
},
{
id1: "Company 73",
id2: "Company 97",
},
{
id1: "Company 75",
id2: "Company 30",
},
{
id1: "Company 50",
id2: "Company 64",
},
{
id1: "Company 77",
id2: "Company 34",
},
{
id1: "Company 6",
id2: "Company 58",
},
{
id1: "Company 26",
id2: "Company 31",
},
{
id1: "Company 99",
id2: "Company 73",
},
{
id1: "Company 2",
id2: "Company 55",
},
{
id1: "Company 43",
id2: "Company 42",
},
{
id1: "Company 78",
id2: "Company 12",
},
{
id1: "Company 94",
id2: "Company 86",
},
{
id1: "Company 31",
id2: "Company 9",
},
{
id1: "Company 92",
id2: "Company 29",
},
{
id1: "Company 43",
id2: "Company 10",
},
{
id1: "Company 77",
id2: "Company 85",
},
{
id1: "Company 6",
id2: "Company 16",
},
{
id1: "Company 47",
id2: "Company 25",
},
{
id1: "Company 44",
id2: "Company 52",
},
{
id1: "Company 15",
id2: "Company 71",
},
{
id1: "Company 16",
id2: "Company 24",
},
{
id1: "Company 94",
id2: "Company 61",
},
{
id1: "Company 81",
id2: "Company 96",
},
{
id1: "Company 15",
id2: "Company 77",
},
{
id1: "Company 1",
id2: "Company 11",
},
{
id1: "Company 86",
id2: "Company 2",
},
{
id1: "Company 96",
id2: "Company 40",
},
{
id1: "Company 53",
id2: "Company 63",
},
];
const nodes = [...new Set(links.map((a) => [a.id1, a.id2]).flat())];
function makeNode(id, icon) {
return {
type: "node",
id,
t: [
{
t: id,
position: "ne",
fc: textColour,
fbc: "rgba(0,0,0,0)",
fb: true,
},
],
c: primaryColour,
fi: {
t: icon,
c: fontIconColour,
},
d: {
type: "company",
},
};
}
function makeLink(id1, id2) {
return {
type: "link",
id: `${id1}-${id2}`,
id1,
id2,
c: linkColour,
w: linkWidth,
a2: true,
};
}
export const data = {
type: "LinkChart",
items: [
...nodes.map((id, i) => makeNode(id, icons[i % icons.length])),
...links.map((link) => makeLink(link.id1, link.id2)),
],
}; <!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="@ci/theme/kl/css/keylines.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/minimalsdk.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/sdk-layout.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/demo.css" />
<link
rel="stylesheet"
type="text/css"
href="@fortawesome/[email protected]/css/fontawesome.css"
/>
<link
rel="stylesheet"
type="text/css"
href="@fortawesome/[email protected]/css/solid.css"
/>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="klchart" class="klchart"></div>
<script type="module" src="./code.js"></script>
</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;
}