//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
//
// Sample Code
//
//! Use donuts to visualise relative volumes of email.
import KeyLines from "keylines";
import { data } from "./emailtraffic-data.js";
// The radius of a node, in world coordinates, without enlargement
const BASE_NODE_RADIUS = 27;
// The distance the tooltip arrow protrudes from the tool tip box
const TOOLTIP_ARROW_SIZE = 11;
const colourToRoles = {
"#FF1529": "Admin", // red - admin
"#FF8027": "Sales", // orange - sales
"#CA62C4": "Other", // purple - other
"#44D161": "Ops", // green - ops
"#29A1E7": "Tech", // blue - tech
"#F781BF": "Marketing", // pink - marketing
"#CB623C": "Support", // brown - support
"#C4C4C4": "External", // grey - external
};
const highlightColours = {
"#D22432": "#FF1529", // red - admin
"#DD6800": "#FF8027", // orange - sales
"#984ea3": "#CA62C4", // purple - other
"#4daf4a": "#44D161", // green - ops
"#377eb8": "#29A1E7", // blue - tech
"#E77BB0": "#F781BF", // pink - marketing
"#a65628": "#CB623C", // brown - support
"#aaaaaa": "#C4C4C4", // grey - external
};
let chart;
let showDonuts = true;
let showNodesWithOneLink = true;
let layoutName = "structural";
let brightSegment = {
id: null,
donutId: null,
originalColour: null,
};
const interactionFormElement = document.getElementById("rhsForm");
/* HELPER FUNCTIONS FOR TOOLTIP BEGIN */
const tooltipElement = document.getElementById("tooltip");
// Determines which (45 degree rotated) quadrant a position lies within
function findQuadrant(item, x, y) {
// Calculate vector from item centre to position
const itemCentre = chart.viewCoordinates(item.x, item.y);
const toPositionCoords = {
x: x - itemCentre.x,
y: y - itemCentre.y,
};
// Determine quadrant using gradient. The dividing lines between quadrants have gradient +/-1
const gradient = Math.abs(toPositionCoords.y / toPositionCoords.x);
if (gradient <= 1 && toPositionCoords.x >= 0) return "right";
if (gradient <= 1 && toPositionCoords.x < 0) return "left";
if (gradient > 1 && toPositionCoords.y >= 0) return "bottom";
return "top";
}
// Calculates the radial distance between the node centre and the donut segment centre
function distanceToTooltip(node) {
return (BASE_NODE_RADIUS + node.donut.bw + node.donut.w / 2) * (node.e || 1);
}
// Returns the hover position shifted radially to the segment centre
function shiftToSegmentCentre(hoverX, hoverY, node) {
// Calculate vector from node centre to hover event position
const nodeCentre = chart.viewCoordinates(node.x, node.y);
const toHoverCoords = {
x: hoverX - nodeCentre.x,
y: hoverY - nodeCentre.y,
};
const initialLength = Math.sqrt(toHoverCoords.x ** 2 + toHoverCoords.y ** 2);
const scaleFactor = distanceToTooltip(node) / initialLength;
// The correction vector from the hover event position to the segment centre
const toCorrectedCoords = {
x: toHoverCoords.x * scaleFactor,
y: toHoverCoords.y * scaleFactor,
};
return chart.viewCoordinates(
node.x + toCorrectedCoords.x,
node.y + toCorrectedCoords.y
);
}
// Offsets the position such that the tooltip arrow aligns correctly
function compensatePosition(tooltip, quadrant, position) {
const newPosition = {};
switch (quadrant) {
case "right":
newPosition.x = position.x + TOOLTIP_ARROW_SIZE;
newPosition.y = position.y - tooltip.clientHeight / 2;
break;
case "left":
newPosition.x = position.x - (tooltip.clientWidth + TOOLTIP_ARROW_SIZE);
newPosition.y = position.y - tooltip.clientHeight / 2;
break;
case "bottom":
newPosition.x = position.x - tooltip.clientWidth / 2;
newPosition.y = position.y + TOOLTIP_ARROW_SIZE;
break;
case "top":
newPosition.x = position.x - tooltip.clientWidth / 2;
newPosition.y = position.y - (tooltip.clientHeight + TOOLTIP_ARROW_SIZE);
break;
default:
break;
}
return newPosition;
}
// Populates the tooltip with hover information
function populateTooltip(label, percentage, direction) {
// Reset the tooltip class list and append the current direction
tooltipElement.className = "popover";
tooltipElement.classList.add(`${direction}`);
// Fill in label and percentage text
document.getElementById("tooltip-label").innerText = `${label}:`;
document.getElementById("tooltip-percentage").innerText = `${percentage}%`;
}
// Fills the tooltip with relevant details and subsequently positions it
function populateAndPositionTooltip(x, y, item, donutId) {
const total = item.donut.v.reduce((a, b) => a + b, 0);
const percentage = Math.round((item.donut.v[donutId] / total) * 100);
const quadrant = findQuadrant(item, x, y);
// Add label, percentage and quadrant information to tooltip HTML
populateTooltip(colourToRoles[item.donut.c[donutId]], percentage, quadrant);
// Get position of hover when snapped to segment centre
const segmentCentrePosition = shiftToSegmentCentre(x, y, item);
// Tweak position to ensure tooltip arrow points to segmentCentrePosition
const position = compensatePosition(
tooltipElement,
quadrant,
segmentCentrePosition
);
// Update tooltip position with calculated values
tooltipElement.style.left = `${position.x}px`;
tooltipElement.style.top = `${position.y}px`;
}
// Hides the tooltip by setting the visibility to hidden
function closeTooltip() {
if (tooltipElement) tooltipElement.style.visibility = "hidden";
}
// Shows the tooltip by setting the visibility to visible
function openTooltip() {
if (tooltipElement) tooltipElement.style.visibility = "visible";
}
/* HELPER FUNCTIONS FOR TOOLTIP END */
// Performs a layout
async function doLayout() {
await chart.layout(layoutName);
}
// Reveals or hides donuts on all nodes
async function showHideDonuts() {
const updatedProperties = [];
chart.each({ type: "node" }, (node) => {
updatedProperties.push({
id: node.id,
donut: {
w: showDonuts ? node.d.w : 0,
bw: showDonuts ? 2 : 0,
},
});
});
await chart.animateProperties(updatedProperties, { time: 250 });
}
// Filters the nodes based on selected options
async function filterNodes() {
if (showNodesWithOneLink) {
// Reveal all nodes
await chart.filter(() => true);
} else {
// Filter out nodes with a degree less than or equal to 1
const degrees = chart.graph().degrees();
await chart.filter((node) => degrees[node.id] > 1, { type: "node" });
}
await doLayout();
}
// Replaces the colour of the desired donut segment with a highlight counterpart
function makeSegmentBrighter(item, donutId) {
const donut = item.donut;
const originalColour = donut.c[donutId];
brightSegment = { id: item.id, donutId, originalColour };
donut.c[donutId] = highlightColours[originalColour];
chart.setProperties({ id: item.id, donut });
}
// Reverts the previously brightened donut segment (if any)
function clearBrightening() {
const item = chart.getItem(brightSegment.id);
if (item) {
const donut = item.donut;
donut.c[brightSegment.donutId] = brightSegment.originalColour;
chart.setProperties({ id: brightSegment.id, donut });
}
}
// If the provided sub item is a donut segment, it is brightened and a tooltip is shown
function highlightSegmentAndShowTooltip({ id, x, y, subItem }) {
clearBrightening();
const item = chart.getItem(id);
if (item && subItem.type === "donut") {
makeSegmentBrighter(item, subItem.index);
populateAndPositionTooltip(x, y, item, subItem.index);
openTooltip();
} else {
closeTooltip();
}
}
// Enables or disables interaction with the chart controls
function disableInteraction(disable) {
interactionFormElement.style.pointerEvents = disable ? "none" : "auto";
}
// Toggles any number of classes on a given element
function toggleClasses(element, ...classes) {
classes.forEach((c) => element.classList.toggle(c));
}
// Inverts the active class name for all buttons in a parent container
function swapButtons(parentId) {
const btns = document.getElementById(parentId).getElementsByClassName("btn");
Array.from(btns).forEach((btn) => toggleClasses(btn, "active", "btn-kl"));
}
// Handler for donut visibility button group
async function onDonutInputChange(shouldShow) {
if (shouldShow !== showDonuts) {
disableInteraction(true);
showDonuts = !showDonuts;
swapButtons("donut-btns");
await showHideDonuts();
disableInteraction(false);
}
}
// Handler for single link visibility button group
async function onOneLinkInputChange(shouldShow) {
if (shouldShow !== showNodesWithOneLink) {
disableInteraction(true);
showNodesWithOneLink = !showNodesWithOneLink;
swapButtons("onelink-btns");
await filterNodes();
disableInteraction(false);
}
}
// Handler for layout selection button group
async function onLayoutInputChange(newLayout) {
if (newLayout !== layoutName) {
disableInteraction(true);
layoutName = newLayout;
swapButtons("layout-btns");
await doLayout();
disableInteraction(false);
}
}
function foregroundSelectedItems() {
const selection = chart.selection();
// If applicable, foreground the items neighbouring the selection
if (selection.length > 0) {
const nodesToForeground = chart
.graph()
.neighbours(selection)
.nodes.concat(selection);
chart.foreground((node) => nodesToForeground.includes(node.id));
} else {
chart.foreground(() => true);
}
}
function attachEventHandlers() {
// Attach event listeners for button pairs
document
.getElementById("btn-show-donuts")
.addEventListener("click", () => onDonutInputChange(true));
document
.getElementById("btn-hide-donuts")
.addEventListener("click", () => onDonutInputChange(false));
document
.getElementById("btn-show-onelink")
.addEventListener("click", () => onOneLinkInputChange(true));
document
.getElementById("btn-hide-onelink")
.addEventListener("click", () => onOneLinkInputChange(false));
document
.getElementById("btn-organic-layout")
.addEventListener("click", () => onLayoutInputChange("organic"));
document
.getElementById("btn-structural-layout")
.addEventListener("click", () => onLayoutInputChange("structural"));
// Attach handler to selection change event
chart.on("selection-change", foregroundSelectedItems);
// Attach handler to pointer-move, so when pointer is over a donut segment,
// we highlight and show a tooltip
chart.on("pointer-move", highlightSegmentAndShowTooltip);
// Close the tooltip to prevent it from pointing to the wrong position
chart.on("view-change", closeTooltip);
}
async function loadKeyLines() {
const options = {
logo: { u: "/images/Logo.png" },
iconFontFamily: "Font Awesome 5 Free",
handMode: true,
hover: 5, // Trigger the hover event with a 5ms delay
};
chart = await KeyLines.create({ container: "klchart", options });
chart.load(data);
doLayout();
attachEventHandlers();
}
function loadFontsAndStart() {
document.fonts.load('24px "Font Awesome 5 Free"').then(loadKeyLines);
}
window.addEventListener("DOMContentLoaded", loadFontsAndStart);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
<head>
<meta charset="UTF-8">
<title>Donuts and Email Traffic</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="/emailtraffic.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="/emailtraffic.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 class="klchart" id="klchart">
<div id="tooltip-container">
<div class="popover" id="tooltip">
<div class="popover-content">
<table class="table-condensed">
<tbody>
<tr>
<td style="text-align: right;"><strong id="tooltip-label"></strong></td>
<td id="tooltip-percentage"></td>
</tr>
</tbody>
</table>
</div>
</div>
</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%;">Donuts and Email Traffic</text>
</svg>
</div>
<div class="tab-content-panel" data-tab-group="rhs">
<div class="toggle-content is-visible tabcontent" id="controlsTab">
<p>Donuts show the proportion of emails sent by nodes to departments in an organisation.</p>
<form autocomplete="off" onsubmit="return false" id="rhsForm">
<div class="cicontent">
<p>Hover over or tap a donut segment to see the percentage of emails it represents.</p>
<h5>Donuts:</h5>
<div class="btn-group" id="donut-btns">
<input class="btn btn-kl active" type="button" value="Show" id="btn-show-donuts">
<input class="btn" type="button" value="Hide" id="btn-hide-donuts">
</div>
<h5>Nodes that only ever email one department:</h5>
<div class="btn-group" id="onelink-btns">
<input class="btn btn-kl active" type="button" value="Show" id="btn-show-onelink">
<input class="btn" type="button" value="Hide" id="btn-hide-onelink">
</div>
<h5>Layout:</h5>
<div class="btn-group" id="layout-btns">
<input class="btn" type="button" value="Organic" id="btn-organic-layout">
<input class="btn active" type="button" value="Structural" id="btn-structural-layout">
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
.klchart {
overflow: hidden;
}
.popover {
display: block;
position: absolute;
min-width: 50px;
margin: 0;
pointer-events: none;
visibility: hidden;
background-color: #fff;
border: 1px solid #ccc;
}
.popover-content {
z-index: 1;
}
Loading source
