Search

Path Analysis in Trees

Links

Visualise hierarchical data with angled links.

Path Analysis in Trees
View live example →

This demo shows how relationships between nodes in a large data hierarchy can be shown effectively, and explored.

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:

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;
}

Terms of use

These terms do not alter or supersede any existing agreements between you (or your employer) and us.

By accessing or using any Content you agree to be bound by these Terms of Use. Please review these terms carefully before using the website.

The contents of this website, including but not limited to any text, code samples, API references, schemas, interactive tools, and other materials (collectively, the 'Content'), are made available for informational and internal evaluation purposes only. All intellectual property rights in the Content are reserved. No licence is granted to use the Content for any commercial purpose, or to copy, distribute, modify, reverse-engineer, or incorporate any part of the Content into any product or service, without our prior written consent.

This Content is provided “as is” and “as available,” without any representations, warranties, or guarantees of any kind, whether express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, non-infringement, or accuracy. To the fullest extent permitted by applicable law, we expressly exclude and disclaim all implied warranties, conditions, and other terms that might otherwise be implied.

We disclaim all liability for any loss or damage, whether direct, indirect, incidental, consequential, or otherwise, arising from any reliance placed on the Content or from your use of it, to the fullest extent permitted by applicable law. By continuing to access or use the Content, you acknowledge and agree to these terms.