//
//     Copyright © 2011-2025 Cambridge Intelligence Limited.
//     All rights reserved.
//
//     Sample Code
//
//!    Explore bitcoin transactions and their blockchain structure.
import KeyLines from "keylines";
import { data } from "./bitcoin-data.js";

let chart;
let comboIds;
let graph;
let timebar;

const currentViewState = {
  index: 0, // tracks currentViewState
  itemInfo: () => {}, // shows rhs info
  stateTransition: false, // flag to override timebar change event during view changes
};
// Text of differents views
const views = {
  0: document.getElementById("view0"),
  1: document.getElementById("view1"),
  2: document.getElementById("view2"),
  3: document.getElementById("view3"),
};
// Text between buttons
const viewEl = document.getElementById("view");
// State chage buttons
const button = {
  next: document.getElementById("next"),
  prev: document.getElementById("prev"),
};

// Generates and renders the rhs information text
function getSelectedItemInfo() {
  const currencySymbols = { btc: "₿", usd: "$" };
  const contentEl = document.getElementById("content");
  const headerEl = document.getElementById("header");
  function format(string) {
    return string.charAt(0).toUpperCase() + string.slice(1);
  }
  function truncate(string) {
    return string.substring(0, Math.min(25, string.length)).concat("...");
  }
  function addCommas(string) {
    return string.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  }
  function highlight(float) {
    const splitString = float.toFixed(2).split(".");
    return `<b>${addCommas(splitString[0])}</b>.${splitString[1]}`;
  }
  const handlers = {
    value: {
      label: "Value",
      content: (d) =>
        `${currencySymbols.btc + d.btc} / ${
          currencySymbols.usd + highlight(d.usd)
        }`,
    },
    hash: { label: "Hash", content: (d) => truncate(d) },
    type: { label: "Type", content: (d) => format(d) },
    level: { label: "Time", content: (d) => new Date(d).toUTCString() },
    address: { label: "Address", content: (d) => truncate(d) },
    transaction: {
      label: "Transaction",
      content: (d) => truncate(chart.getItem(d).d.hash),
    },
  };
  function getContent(props) {
    return Object.keys(props)
      .map((key) => {
        const handler = handlers[key];
        return `<tr><td><strong>${
          handler.label
        }</strong></td><td>${handler.content(props[key])}</td></tr>`;
      })
      .join("");
  }
  return {
    show: (props) => {
      headerEl.style.display = props ? "none" : "block";
      contentEl.innerHTML = props ? getContent(props) : "";
    },
  };
}

// Converts dates to milliseconds
function toMS(dt) {
  return Date.UTC(
    dt.getFullYear(),
    dt.getMonth(),
    dt.getDate(),
    dt.getHours(),
    dt.getMinutes(),
    dt.getSeconds(),
    dt.getMilliseconds()
  );
}

// Make the timebar range a bit broader
async function setTimebarRange() {
  const offset = 1000;
  const range = await timebar.range();
  await timebar.range(toMS(range.dt1) - offset, toMS(range.dt2) + offset, {
    animate: false,
  });
}

async function foregroundItemsInRange() {
  await chart.foreground(timebar.inRange);
}

async function closeCombos() {
  await chart.combo().close(comboIds, { animate: false });
}

// Hides some autogenerated arrows on combolinks
function hideArrowsFromComboLinks() {
  const props = [];
  chart.each({ type: "link", items: "toplevel" }, (link) => {
    if (chart.combo().isCombo(link.id2)) {
      props.push({ id: link.id, a1: false, a2: false, c2: "#d3d3d3" });
    }
  });
  chart.setProperties(props);
}

async function revealLinks(item) {
  const itemType = item.d.type;
  if (itemType === "transaction") {
    const linksToReveal = [];
    const neighbours = await graph.neighbours(item.id);
    neighbours.links.forEach((linkId) => {
      const link = chart.getItem(linkId);
      const isChildNode = !!chart.getItem(link.id2).parentId;
      const isOpenCombo = !!chart.combo().isOpen(chart.combo().find(link.id2));
      if (isChildNode && isOpenCombo) linksToReveal.push(linkId);
    });
    chart.combo().reveal(linksToReveal);
  } else if ((itemType === "input" || itemType === "output") && item.parentId) {
    const linksToReveal = await graph.neighbours(item.id).links;
    chart.combo().reveal(linksToReveal);
  }
}

function showInfoAndRevealLinks() {
  currentViewState.itemInfo.show();
  chart.combo().reveal([]);
  const item = chart.getItem(chart.selection()[0]);
  if (item && item.type === "node") {
    currentViewState.itemInfo.show(item.d);
    revealLinks(item);
  }
}

// Zoom to fit all items
async function zoomToFit() {
  await Promise.all([
    chart.zoom("fit", { animate: true, time: 1000 }),
    timebar.zoom("fit", { animate: true, time: 1000 }),
  ]);
  await foregroundItemsInRange();
}

// Zoom to specific items
async function zoomToIds(ids) {
  await Promise.all([
    chart.zoom("fit", { animate: true, time: 1000, ids }),
    timebar.zoom("fit", { animate: true, time: 1000, id: ids }),
  ]);
  await setTimebarRange();
  await foregroundItemsInRange();
}

async function dismissChain() {
  await zoomToFit();
  await chart.filter(() => true, { type: "node" });
  await chart.hide(data.chainLinkIds);
  await chart.animateProperties(data.layout.organic);
  comboIds = await chart.combo().combine(data.comboDefs, { select: false });
  hideArrowsFromComboLinks();
  await zoomToFit();
}

async function inspectChain() {
  await zoomToIds(data.chainNodeIds);
  await chart.combo().uncombine(comboIds || data.comboIds, { select: false });
  await chart.filter((node) => data.chainNodeIds.includes(node.id), {
    type: "node",
  });
  await chart.show(data.chainLinkIds);
  await chart.animateProperties(data.layout.sequential);
  await zoomToFit();
}

async function dismissTxs() {
  await chart.filter(() => true, { type: "link", items: "toplevel" });
  await zoomToFit();
}

async function inspectTxs(ids) {
  const neighbours = await chart.graph().neighbours(ids, { hops: Infinity });
  await chart.filter((link) => neighbours.links.includes(link.id), {
    type: "link",
    items: "toplevel",
  });
  await zoomToIds([...ids, ...neighbours.nodes, ...neighbours.links]);
}

// Introduce a 500ms pause to make zooming transitions easier to follow;
async function pauseAnimation() {
  await new Promise((resolve) => setTimeout(resolve, 500));
}

// Action mapping for forward currentViewState changes
async function getPrevState() {
  await {
    0: async () => {
      await dismissTxs();
    },
    1: async () => {
      await dismissTxs();
      await pauseAnimation();
      await inspectTxs(["t1013", "t886"]);
    },
    2: async () => {
      await dismissChain();
      await pauseAnimation();
      await inspectTxs(["t282", "t283"]);
    },
    3: async () => {
      await inspectChain();
    },
  }[currentViewState.index % 4]();
}

// Action mapping for backwards currentViewState changes
async function getNextState() {
  await {
    0: async () => {
      await dismissChain();
    },
    1: async () => {
      await inspectTxs(["t1013", "t886"]);
    },
    2: async () => {
      await dismissTxs();
      await pauseAnimation();
      await inspectTxs(["t282", "t283"]);
    },
    3: async () => {
      await dismissTxs();
      await pauseAnimation();
      await inspectChain();
    },
  }[currentViewState.index % 4]();
}

function disableUI() {
  button.prev.disabled = true;
  button.next.disabled = true;
}

function enableUI() {
  button.prev.disabled = currentViewState.index === 0;
  button.next.disabled = false;
}

// Update view text and text between buttons
function updateView() {
  Object.keys(views).forEach((i) => {
    views[i].style.display = "none";
  });
  views[currentViewState.index % 4].style.display = "block";
  viewEl.innerHTML = `${(currentViewState.index % 4) + 1} of 4`;
}

function enableUserInteraction() {
  enableUI();
  chart.on("selection-change", showInfoAndRevealLinks);
  // Disable node dragging
  chart.on("drag-start", ({ preventDefault, type }) => {
    if (type === "node") {
      preventDefault();
    }
  });

  timebar.on("change", foregroundItemsInRange);

  // Click handlers
  const handlers = {
    prev: async () => {
      currentViewState.index--;
      updateView();
      await getPrevState();
    },
    next: async () => {
      currentViewState.index++;
      updateView();
      await getNextState();
    },
  };

  ["prev", "next"].forEach((id) => {
    button[id].addEventListener(
      "click",
      async () => {
        // Override change event when zooming and manually foreground items
        timebar.off("change");
        closeCombos();
        disableUI();
        await handlers[id]();
        enableUI();
        timebar.on("change", foregroundItemsInRange);
      },
      false
    );
  });
}

async function startKeyLines() {
  graph = KeyLines.getGraphEngine();
  graph.load(data);
  currentViewState.itemInfo = getSelectedItemInfo();

  const chartOpts = {
    backColour: "#282828",
    controlTheme: "dark",
    defaultStyles: { comboGlyph: null },
    drag: { links: false },
    handMode: true,
    iconFontFamily: "Font Awesome 5 Free",
    imageAlignment: { "fas fa-exchange-alt": { e: 0.7 } },
    minZoom: 0.001,
    overview: { icon: false, shown: false },
    selectionColour: "#d3d3d3",
  };

  const timebarOpts = {
    area: { colour: "#d24dff" },
    backColour: "#282828",
    controlBarTheme: "dark",
    scale: { highlightColour: "#363636" },
    sliders: "none",
    type: "area",
  };

  [chart, timebar] = await KeyLines.create([
    { container: "klchart", type: "chart", options: chartOpts },
    { container: "kltimebar", type: "timebar", options: timebarOpts },
  ]);

  await chart.load(data);
  await timebar.load(data);
  await zoomToFit();
  hideArrowsFromComboLinks();
  enableUserInteraction();
}

function loadWebFonts() {
  document.fonts.load('24px "Font Awesome 5 Free"').then(startKeyLines);
}

window.addEventListener("DOMContentLoaded", loadWebFonts);
<!DOCTYPE html>
<html lang="en" style="background-color: #2d383f;">
  <head>
    <meta charset="UTF-8">
    <title>Bitcoin Transactions</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="/bitcoin.css">
    <link rel="stylesheet" type="text/css" href="/css/fontawesome5/fontawesome.css">
    <link rel="stylesheet" type="text/css" href="/css/fontawesome5/solid.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="/bitcoin.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 klchart-timebar" id="klchart">
          </div>
          <div class="kltimebar" id="kltimebar">
          </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%;">Bitcoin Transactions</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="cipad cicontent">
                <div class="buttons">
                  <input class="btn" type="button" value="&lt;" disabled style="margin:10px" id="prev">
                  <div id="view">1 of 4</div>
                  <input class="btn" type="button" value="&gt;" disabled style="margin:10px" id="next">
                </div>
                <div class="view" style="display:block;" id="view0">
                  <p>Blockchain transactions are often fast-paced and complex.</p>
                  <p>This view shows bitcoin transactions during a period of 6 minutes.</p>
                  <p>
                    Transactions, inputs and outputs are represented by nodes, with links
                    representing involvement in a transaction.
                  </p>
                  <p>Interact with the time bar to filter transactions outside the range.</p>
                </div>
                <div class="view" style="display:none;" id="view1">
                  <p>Bitcoin transactions have an input, output, and value.</p>
                  <p>
                    The transaction on the right shows the sale by one address
                    (blue node) to 2 others (orange nodes).
                  </p>
                  <p>
                    The transaction on the left shows a transaction where
                    an input and an output share the same address.
                    This is common when change from a transaction
                    is returned to the original address.
                    
                  </p>
                </div>
                <div class="view" style="display:none;" id="view2">
                  <p>Large transactions may feature many addresses combining their resources.</p>
                  <p>Typically this indicates multiple addresses tied to the same wallet.</p>
                  <p>
                    The transactions here involve two large sums of money (totalling over US $72,000)
                    being transferred in quick succession.
                    
                  </p>
                </div>
                <div class="view" style="display:none;" id="view3">
                  <p>
                    Transactions can form long chains when the same bitcoin
                    are quickly transferred between addresses.
                  </p>
                  <p>The sequential layout can show the progress of capital over time.</p>
                  <p>
                    Addresses that are involved in multiple transactions within a single blockchain
                    form closed loops in the graph, and are highlighted here in red and green.
                    
                  </p>
                </div>
                <div style="text-align:center;">
                  <h3>Node Legend</h3>
                </div>
                <div class="svg-grid">
                  <svg class="c1">
                    <circle cx="20" cy="20" r="14" stroke="#04b5e5" fill="#282828" stroke-width="6"></circle>
                    <text x="45" y="24.5" fill="#282828">Input</text>
                  </svg>
                  <svg class="c2">
                    <circle cx="20" cy="20" r="14" stroke="#f2a900" fill="#282828" stroke-width="6"></circle>
                    <text x="45" y="24.5" fill="#282828">Output</text>
                  </svg>
                  <svg class="c3">
                    <circle cx="20" cy="20" r="14" stroke="#d3d3d3" fill="#282828" stroke-width="6"></circle>
                    <text class="fas" x="13" y="24.5" fill="#d24dff">&#xf362;</text>
                    <text x="45" y="24.5" fill="#282828">Transaction</text>
                  </svg>
                  <svg class="c4">
                    <circle cx="20" cy="20" r="14" stroke="#f2a900" fill="#282828" stroke-width="6"></circle>
                    <circle cx="16" cy="20" r="3" fill="#04b5e5"></circle>
                    <circle cx="24" cy="20" r="3" fill="#f2a900"></circle>
                    <text x="45" y="24.5" fill="#282828">Shared Address</text>
                  </svg>
                </div>
                <div style="text-align:center; pointer-events:none;">
                  <h3>Details</h3>
                  <h5 id="header">Click on nodes for further information</h5>
                  <table>
                    <tbody id="content"></tbody>
                  </table>
                </div>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
    <div id="moreParent">
      <div id="moreContainer">
      </div>
    </div>
    <div id="lazyScripts">
    </div>
  </body>
</html>
.klchart {
  border: none;
  background-color: #282828;
}
.kltimebar {
  border: none;
  border-top: 1px dashed #333;
  background-color: #282828;
}
td {
  text-align: left;
  padding: 5px;
}
#view {
  width: 37px;
}
.view {
  min-height: 220px;
}
.buttons {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 60px;
}
.svg-grid > svg {
  width: 100%;
  height: 40px;
}
.svg-grid{
  margin:20px;
  display: grid;
  grid-gap: 5px;
  grid-template: 1fr 1fr/ 1fr 1fr;
}
.c1 {
  grid-row: 1/2;
  grid-column: 1/2;
}
.c2 {
  grid-row: 1/2;
  grid-column: 2/-1;
}
.c3 {
  position: relative;
  grid-row: 2/-1;
  grid-column: 1/2;
}
.c4 {
  grid-row: 2/-1;
  grid-column: 2/-1;
}
Loading source