//
// 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="<" disabled style="margin:10px" id="prev">
<div id="view">1 of 4</div>
<input class="btn" type="button" value=">" 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"></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
