//
// Copyright © 2011-2025 Cambridge Intelligence Limited.
// All rights reserved.
// TWITTER, TWEET and RETWEET are the trademarks of X, Inc.
//
// Sample Code
//
//! Explore connections between social media accounts.
import KeyLines from "keylines";
import { data, style } from "./socialmedia-data.js";
let chart;
let hoveredGlyph = null;
const expandLayoutOpts = {
fit: true,
name: "organic",
mode: "adaptive",
};
function getGlyphs(item) {
const isOpenCombo = chart.combo().isOpen(item.id);
return isOpenCombo ? item.oc.g : item.g;
}
function setGlyphs(id, glyphs, isOpenCombo = chart.combo().isOpen(id)) {
const g = isOpenCombo ? { oc: { g: glyphs } } : { g: glyphs };
chart.setProperties({ id, ...g });
}
function getHoveredGlyph(item, subId) {
if (item) {
if (item.d.type === "post" && item.g && item.g.length === 1) {
return 0;
}
const glyphs = getGlyphs(item);
if (glyphs) {
return glyphs.findIndex((g) => g.p.toString() === subId);
}
}
}
function chartHoverHandler({ id, subItem: { subId } }) {
const item = chart.getItem(id);
const index = getHoveredGlyph(item, subId);
const isOpenCombo = chart.combo().isOpen(id);
let glyphToHover = null;
// Work out if the cursor is over a glyph which is in the foreground to focus
// i.e. over an expand glyph on a node or a post node with an expand glyph
if (id && item && !item.bg && item.type === "node" && index >= 0) {
glyphToHover = item.id;
}
// Return any previously focussed glyph to normal
if (hoveredGlyph && hoveredGlyph.nodeId !== glyphToHover) {
setGlyphs(
hoveredGlyph.nodeId,
hoveredGlyph.originalGlyphs,
hoveredGlyph.isOpenCombo
);
hoveredGlyph = null;
}
// Focus any newly hovered glyph
if (!hoveredGlyph && glyphToHover) {
const originalGlyphs = getGlyphs(item);
const newGlyphs = originalGlyphs.map((g, i) => {
return i === index ? { ...g, c: style.hoverStyles[g.c] } : g;
});
hoveredGlyph = { nodeId: id, sub: subId, originalGlyphs, isOpenCombo };
setGlyphs(id, newGlyphs);
}
}
function getAllCombos() {
const combos = [];
// In this demo all top level items are combos and there are no nested combos
chart.each({ type: "node", items: "toplevel" }, (combo) => {
combos.push(combo.id);
});
return combos;
}
async function openCombos() {
await chart.combo().open(getAllCombos());
chart.layout("organic", { mode: "adaptive" });
}
async function closeCombos() {
await chart.combo().close(getAllCombos());
chart.layout("organic", { mode: "adaptive" });
}
function getRepostIds(platformInfo, postId) {
return data.rawData[platformInfo.data]
.filter((p) => p[platformInfo.copyOf] === postId)
.map((p) => p.id);
}
/* Create chart items */
function createPostNode(platformInfo, post, owner) {
const reposts = getRepostIds(platformInfo, post.id);
const colour =
post[platformInfo.copyOf] || reposts.length > 0
? platformInfo.colour
: platformInfo.fadedColour;
return {
id: post.id,
type: "node",
fi: { t: platformInfo.postIcon, c: colour },
e: 0.75,
d: {
type: "post",
platform: platformInfo.name,
idx: post.idx,
copyOf: post[platformInfo.copyOf],
reposts,
},
parentId: owner,
};
}
function createAuthoredByLink(platformInfo, post) {
return {
id: `${platformInfo.author}${post[platformInfo.author]} ${post.id}`,
type: "link",
id1: post[platformInfo.author],
id2: post.id,
a2: true,
c: platformInfo.colour,
d: { type: "authoredBy" },
};
}
function createRepostLink(platformInfo, repostId, postId) {
return {
id: repostId + platformInfo.copyOf + postId,
type: "link",
id1: repostId,
id2: postId,
a1: true,
c: platformInfo.colour,
d: { type: "repost" },
};
}
function createAccountNodes(owner, platformInfo) {
const ownerInfo = data.rawData.owners[owner];
const accountIcon =
platformInfo.name === "twitter"
? platformInfo.accountIcon
: KeyLines.getFontIcon(platformInfo.accountIcon);
return ownerInfo[`${platformInfo.name}Accounts`].map((account) => ({
id: account.id,
type: "node",
c: "transparent",
borderRadius: 10,
bw: 1,
b: "rgba(255, 255, 255, 1)",
e: 0.8,
fc: style.textColour,
fs: 16,
sh: "box",
t: [
{
t: account.name,
borderRadius: 20,
bw: 0,
padding: "3 5 0 5",
fbc: "rgba(255, 255, 255, 0.1)",
b: "rgba(200, 200, 200, 1)",
},
],
fi: { t: accountIcon, c: platformInfo.accountIconColour },
parentId: owner,
d: { type: "account", platform: platformInfo.name },
}));
}
function getPlatformContentBy(owner, platform, links) {
const platformInfo = style.platformDetails[platform];
const platformPosts = data.rawData[platformInfo.data];
const accountNodes = createAccountNodes(owner, platformInfo);
const accounts = accountNodes.map((node) => node.id);
const posts = platformPosts
.filter((post) => accounts.includes(post[platformInfo.author]))
.map((post) => {
const node = createPostNode(platformInfo, post, owner);
links.push(createAuthoredByLink(platformInfo, post));
// Expand in all the copyOf links for this post
// If the other end isn't in the chart yet, they will automatically be filtered out
if (post[platformInfo.copyOf]) {
links.push(
createRepostLink(platformInfo, post.id, post[platformInfo.copyOf])
);
}
node.d.reposts.forEach((repost) => {
links.push(createRepostLink(platformInfo, repost, node.id));
});
return node;
});
return [...accountNodes, ...posts];
}
function createEntityCombo(owner) {
const ownerInfo = data.rawData.owners[owner];
const ownerImage = `/im/people/${owner}.png`;
const entityGlyph = {
u: ownerImage,
p: style.entityGlyphPos,
e: 3,
};
const label = {
t: `${ownerInfo.firstname} ${ownerInfo.surname}`,
fs: 16,
borderRadius: 8,
bw: 0,
b: "transparent",
fc: style.textColour,
fbc: "transparent",
position: "s",
padding: "3 5 0 5",
};
let nodes = [
{
id: owner,
type: "node",
e: 2,
fc: style.textColour,
fi: style.entityIcon,
fs: 16,
t: label,
u: ownerImage,
oc: {
c: "rgba(255, 255, 255, 0.05)",
b: "rgba(200, 200, 200, 1)",
bw: 4,
g: [entityGlyph, style.closeComboGlyph],
t: label,
},
g: [style.openComboGlyph],
d: { type: "owner" },
},
];
const links = [];
["twitter", "fb"].forEach((platform) => {
nodes = nodes.concat(getPlatformContentBy(owner, platform, links));
});
return [...nodes, ...links];
}
/* END of functions to create chart items */
// Expand in the neighbours of item not already in the chart
// and if highlight is true, highlight all the neighbours of item
function getRepostsToExpandIn(postId, postData) {
let items = [];
const platformInfo = style.platformDetails[postData.platform];
const allPosts = data.rawData[platformInfo.data];
const reposts = postData.reposts.map((pId) => {
const id = pId.slice(platformInfo.identifier.length);
return { id, dir: "to" };
});
if (postData.copyOf) {
reposts.push({
id: postData.copyOf.slice(platformInfo.identifier.length),
dir: "from",
});
}
// Repost already in the chart => nothing to expand in => filter out
reposts
.filter((repost) => !chart.getItem(platformInfo.identifier + repost.id))
.forEach((repost) => {
const p = allPosts[repost.id];
const author = p[platformInfo.author];
const owner = data.rawData.accounts[postData.platform][author].owner;
// Include all the accounts of this owner
items = items.concat(createEntityCombo(owner));
const origPost = repost.dir === "from" ? p.id : postId;
const repostedTo = repost.id === "from" ? postId : p.id;
const repostLink = createRepostLink(platformInfo, repostedTo, origPost);
items.push(repostLink);
});
return items;
}
// Update the expand glyphs on current chart items to correctly identify which can be expanded
function updateGlyphs() {
const props = [];
const expandableCombos = {};
const graphEngine = KeyLines.getGraphEngine();
graphEngine.load(chart.serialize());
const degrees = graphEngine.degrees();
chart.each({ type: "node" }, (node) => {
if (node.d && node.d.type === "post") {
let numReposts = node.d.reposts ? node.d.reposts.length : 0;
if (node.d.copyOf) numReposts++;
if (numReposts > 0) {
const inChart = degrees[node.id] - 1;
if (inChart !== numReposts) {
props.push({ id: node.id, g: [style.expandGlyph] });
const parentCombo = chart.getItem(node.parentId);
props.push({
id: node.parentId,
g: [style.openComboGlyph, style.expandGlyph],
oc: {
...parentCombo.oc,
g: [...parentCombo.oc.g, style.expandGlyph],
},
});
expandableCombos[node.parentId] = true;
} else {
props.push({ id: node.id, g: [] });
}
}
}
});
// Check if glyph needs removing from any combos
chart.each({ type: "node", items: "toplevel" }, (combo) => {
if (!expandableCombos[combo.id]) {
props.push({
id: combo.id,
g: [combo.g[0]],
oc: { g: combo.oc.g.slice(0, 2) },
});
}
});
chart.setProperties(props);
}
async function loadStartingNode() {
// data is defined in socialmedia-data.js
chart.clear();
const startingCombo = createEntityCombo(data.initialEntity);
await chart.expand(startingCombo, {
layout: expandLayoutOpts,
time: 200,
arrange: { name: "lens" },
});
exploreOutwards(startingCombo[0]);
chart.selection([startingCombo[0].id]);
updateGlyphs();
styleLabels(chart.selection());
}
/**
* Get the items to expand into the chart so that all direct reposts or previous posts of
* postId (the data about which is in the postData object) are included in the chart
* (including the details of their containing combos)
*/
function getAllCopiesOfPost(postId) {
let origPostId = postId;
let origPost = chart.getItem(postId);
while (origPost.d.copyOf) {
origPost = chart.getItem(origPost.d.copyOf);
// Only store ID and continue search for original if node in the chart
if (origPost) origPostId = origPost.id;
else break;
}
let reposts = [origPostId];
for (let i = 0; i < reposts.length; i++) {
const repost = chart.getItem(reposts[i]);
if (repost) {
reposts = [...reposts, ...repost.d.reposts];
}
}
return reposts;
}
function isParentOpen(nodeId) {
return chart.combo().isOpen(chart.combo().find(nodeId));
}
/**
* Foreground the item defined by id and its linked items.
* For owners this is just their neighbours
* For accounts this is all accounts that have interacted with it and all posts by those accounts
* (including the original account)
* For posts this is all copies of the post and the accounts making those posts
*/
function revealAndForeground(linksToReveal, itemsToForeground, fgItems) {
const bgLinks = [];
// Only reveal links if at least one end inside an open combo
const toReveal = linksToReveal.filter((linkId) => {
const link = chart.getItem(linkId);
if (isParentOpen(link.id1) || isParentOpen(link.id2)) {
// Background any parent link unless it is explicitly set to be foregrounded
if (
link.parentId &&
chart.getItem(link.parentId).type === "link" &&
!itemsToForeground.includes(link.parentId)
) {
bgLinks.push(link.parentId);
}
return true;
}
return false;
});
chart.combo().reveal(toReveal);
chart.foreground((item) => itemsToForeground.includes(item.id), {
type: "all",
items: fgItems,
});
// background all combolinks of revealed links
chart.setProperties(bgLinks.map((linkId) => ({ id: linkId, bg: true })));
}
function highlightRelations(ids) {
const clickedNodes = chart
.getItem(ids)
.filter((item) => item.type === "node");
let fgItems;
if (clickedNodes.length > 0) {
let nbrs;
let itemsToForeground = [...ids];
const linksToReveal = [];
const graphEngine = KeyLines.getGraphEngine();
graphEngine.load(chart.serialize());
clickedNodes.forEach((clicked) => {
if (clicked.d.type === "owner") {
fgItems = "toplevel";
nbrs = chart.graph().neighbours(clicked.id);
itemsToForeground = itemsToForeground.concat(nbrs.nodes, nbrs.links, [
clicked.id,
]);
} else {
fgItems = "underlying";
if (clicked.d.type === "account") {
// Get the accounts that have interacted with this account
const nbrs3 = graphEngine.neighbours(clicked.id, { hops: 3 });
const connectedAccounts = nbrs3.nodes.filter((nodeId) => {
const isAccount = chart.getItem(nodeId).d.type === "account";
return isAccount;
});
connectedAccounts.push(clicked.id);
// Get the posts by these accounts (and links to them)
nbrs = graphEngine.neighbours(connectedAccounts);
// Get underlying links between the accounts
// (these won't be revealed but the combolink will be foregrounded)
const interAccountLinks = graphEngine.neighbours(clicked.id, {
hops: 2,
}).links;
itemsToForeground = itemsToForeground.concat(
nbrs.nodes,
nbrs.links,
connectedAccounts,
interAccountLinks
);
} else {
// clicked.d.type === 'post'
const copiesOfPost = getAllCopiesOfPost(clicked.id);
itemsToForeground = [...itemsToForeground, ...copiesOfPost];
chart.each({ type: "link" }, (link) => {
// Also foreground accounts which posted these posts
// (account is always id1 on a postedBy link)
if (copiesOfPost.includes(link.id2)) {
if (!copiesOfPost.includes(link.id1)) {
itemsToForeground.push(link.id1);
}
linksToReveal.push(link.id);
}
});
itemsToForeground = [...itemsToForeground, ...linksToReveal];
}
}
});
revealAndForeground(linksToReveal, itemsToForeground, fgItems);
} else {
chart.combo().reveal([]);
chart.foreground(() => true);
}
}
function styleLabels(selection) {
const propertiesToSet = [];
chart.each({ type: "node", items: "toplevel" }, (node) => {
const [currentLabel] = node.t;
if (currentLabel === undefined) return;
let newLabel;
if (selection.includes(node.id)) {
// add coloured background
newLabel = {
...currentLabel,
fbc: style.selectionColour,
};
} else {
// remove styling
newLabel = {
...currentLabel,
fbc: "transparent",
};
}
propertiesToSet.push({
id: node.id,
t: [newLabel],
oc: { ...node.oc, t: [newLabel] },
});
});
chart.setProperties(propertiesToSet);
}
function selectionchangeHandler() {
const selection = chart.selection();
highlightRelations(selection);
styleLabels(selection);
}
async function exploreOutwards(item, highlight) {
let expandIn = [];
if (!item || !item.id || item.type !== "node" || !item.d) {
return;
}
if (item.d.type === "post") {
// Add in retweets
expandIn = getRepostsToExpandIn(item.id, item.d);
} else if (item.d.type === "owner") {
const content = chart.combo().info(item.id).nodes;
content.forEach((t) => {
if (t.d.type === "post") {
expandIn = expandIn.concat(getRepostsToExpandIn(t.id, t.d));
}
});
} else {
return;
}
// Prevent opening/closing of combos while data is being added to the chart
chart.on("double-click", ({ preventDefault }) => preventDefault());
await chart.expand(expandIn, {
layout: expandLayoutOpts,
arrange: { name: "lens" },
time: 700,
});
chart.off("double-click");
updateGlyphs();
if (highlight) highlightRelations([item.id]);
}
function toggleCombo(id) {
const isCombo = chart.combo().isCombo(id);
if (isCombo) {
const isOpen = chart.combo().isOpen(id);
if (isOpen) {
chart.combo().close(id);
} else {
chart.combo().open(id);
}
}
}
function chartClickHandler({ id, subItem: { subId } }) {
const item = chart.getItem(id);
// Only check for glyph clicks if in foreground
if (item && item.type === "node" && !item.bg) {
if (
subId === style.expandGlyphPos.toString() ||
(item.d.type === "post" && item.g && item.g.length === 1)
) {
// Ensure the expand glyph isn't re-added to this node
if (hoveredGlyph && hoveredGlyph.nodeId === id) {
hoveredGlyph = null;
}
exploreOutwards(item, true);
} else if (subId === style.openCloseGlyphPos.toString()) {
toggleCombo(id);
}
}
}
function initialiseInteractions() {
// Setup handlers
chart.on("hover", chartHoverHandler);
chart.on("click", chartClickHandler);
chart.on("selection-change", selectionchangeHandler);
document.getElementById("openCombos").addEventListener("click", openCombos);
document.getElementById("closeCombos").addEventListener("click", closeCombos);
document.getElementById("reset").addEventListener("click", loadStartingNode);
document.getElementById("layout").addEventListener("click", () => {
chart.layout("organic");
});
loadStartingNode();
}
async function startKeyLines() {
// Set offsets and enlargements for Font Icons
const imageAlignment = {
"fas fa-user": { dy: -12, e: 1.2 },
"fas fa-plus": { e: 1.3 },
};
const options = {
arrows: "large",
defaultStyles: {
comboGlyph: null,
comboLinks: { c: "rgb(161, 156, 156)" },
},
handMode: true,
navigation: false,
hover: 0,
iconFontFamily: "Font Awesome 5 Brands",
imageAlignment,
selectionColour: style.selectionColour,
gradient: {
stops: [
{ c: "#734b6d", r: 0 },
{ c: "#42275a", r: 1 },
],
},
};
chart = await KeyLines.create({ container: "klchart", options });
initialiseInteractions();
}
function loadKeyLines() {
Promise.all([
document.fonts.load('24px "Font Awesome 5 Free"'),
document.fonts.load('24px "Font Awesome 5 Brands"'),
]).then(startKeyLines);
}
window.addEventListener("DOMContentLoaded", loadKeyLines);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
<head>
<meta charset="UTF-8">
<title>Social Media Analysis</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="/css/fontawesome5/brands.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="/socialmedia.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">
<ul class="kl-dropdown-menu" id="contextMenuNode" role="menu">
<li><a id="expand" tabindex="-1" href="#">Expand neighbours</a></li>
<li><a id="close" tabindex="-1" href="#">Close combo</a></li>
</ul>
</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%;">Social Media Analysis</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">
<p>Use combos to explore how people are connected based on their social media activity.</p>
<p>
Click the
<i class="fas fa-plus" style="background-color: rgb(183, 92, 170); border-radius: 30px; border: solid 4px rgb(183, 92, 170); color: #fff; font-size: smaller;"></i>
glyphs to bring in the people that a person interacts with.
</p>
<p>
Double click individual combos or click the
<i class="fas fa-expand-arrows-alt" style="background-color: rgb(134, 74, 186); border-radius: 30px; border: solid 5px rgb(134, 74, 186); color: #fff; font-size: smaller;"></i>
glyphs to see each person's social media activity.
</p>
<fieldset>
<legend>Controls</legend>
<input class="btn btn-block" type="button" value="Open all combos" id="openCombos">
<input class="btn btn-block" type="button" value="Close all combos" id="closeCombos">
<input class="btn btn-block" type="button" value="Layout" id="layout">
<input class="btn btn-block" type="button" value="Reset" id="reset">
</fieldset>
<p>The 𝕏 logo is a trademark of X, Inc.</p>
<p>The <i class="fab fa-facebook-f" style=" color: #006cf3;"></i> logo is a trademark of Meta.
</p>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="moreParent">
<div id="moreContainer">
</div>
</div>
<div id="lazyScripts">
</div>
</body>
</html>
Loading source
