//
//     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