Merge pull request #6107 from mempool/mononaut/canvas-unfurl-tx-preview

Unfurler canvas: add tx component
This commit is contained in:
mononaut 2025-12-05 15:18:46 +09:00 committed by GitHub
commit ce3179b018
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 558 additions and 1 deletions

View file

@ -5,6 +5,7 @@ import { IEsploraApi } from "../api/esplora-api.interface";
import { fetchJSON } from "../api/api";
import { getImage } from "./images";
import { renderBlockViz } from "./block-viz/block-viz";
import { renderTxBowtie } from "./tx-bowtie/tx-bowtie";
import { themes } from "./themes";
import { CanvasRenderingContext2D, Image } from 'canvas';
import { formatNumber, formatWeightUnit, middleEllipsis, renderQrToCtx } from "./utils/utils";
@ -65,7 +66,14 @@ export const dataRequirements: Record<string, (id: string) => DataRequirement<an
return await fetchJSON(config.API.ESPLORA + `/block/${hash}`) as IEsploraApi.Block;
}
}),
tx: (txid: string): DataRequirement<IEsploraApi.Transaction> => ({
key: `tx_${txid}`,
fetcher: async () => {
return await fetchJSON(config.API.ESPLORA + `/tx/${txid}`) as IEsploraApi.Transaction;
}
}),
extendedBlock: (id: string): DataRequirement<BlockExtended> => ({
key: `extended_block_${id}`,
fetcher: async () => {
@ -272,6 +280,185 @@ export const components: Record<string, (...args: any[]) => Component> = {
}
}),
txContent: (txid: string, position: Position): Component => {
const bounds = { ...position, w: position.w ?? 1200, h: position.h ?? 520 }
const boxPadding = 10;
const boxHeight = 366;
const boxMargin = { left: 48, right: 48, bottom: 24 };
const boxX = bounds.x + boxMargin.left;
const boxY = bounds.y + bounds.h - boxMargin.bottom - boxHeight;
const boxW = bounds.w - boxMargin.left - boxMargin.right;
return {
type: 'tx',
data: [
dataRequirements.tx(txid),
],
children: [
components.txViz(txid, {
x: boxX + boxPadding,
y: boxY + boxPadding,
w: boxW - (boxPadding * 2),
h: boxHeight - (boxPadding * 2)
}),
],
render: async (ctx: CanvasRenderingContext2D, data: any): Promise<void> => {
const tx = data[`tx_${txid}`];
const totalValue = tx.vout.reduce((acc: number, v: any) => (v.value || 0) + acc, 0);
const isCoinbase = tx.vin.some((v: any) => v.is_coinbase === true);
ctx.fillStyle = '#181b2d';
ctx.fillRect(boxX, boxY, boxW, boxHeight);
ctx.font = 'bold 50px Roboto';
ctx.fillStyle = themes.default.fg;
ctx.textAlign = 'left';
const txidWidth = bounds.w - 96;
ctx.fillText(middleEllipsis(ctx, txid, txidWidth), bounds.x + 48, bounds.y + 50);
const secondRowY = boxY - 20;
ctx.font = 'bold 32px Roboto';
ctx.fillStyle = themes.default.fg;
ctx.textAlign = 'left';
const amountNum = formatNumber(totalValue / 1e8, '1.8-8');
ctx.fillText(amountNum, bounds.x + 48, secondRowY);
const amountWidth = ctx.measureText(amountNum).width;
ctx.font = '24px Roboto';
ctx.fillStyle = themes.default.symbol;
ctx.fillText('BTC', bounds.x + 48 + amountWidth + 6, secondRowY + 2);
if (tx.status?.block_time) {
ctx.font = 'bold 32px Roboto';
ctx.fillStyle = themes.default.fg;
ctx.textAlign = 'center';
const datetime = new Date(tx.status.block_time * 1000).toLocaleString('sv-SE').replace(',', '').slice(0, 16);
ctx.fillText(datetime, bounds.x + bounds.w / 2, secondRowY);
}
ctx.font = 'bold 32px Roboto';
ctx.fillStyle = themes.default.fg;
ctx.textAlign = 'right';
const feeNum = formatNumber(tx.fee, '1.0-0');
const feeNumWidth = ctx.measureText(feeNum).width;
ctx.font = '24px Roboto';
ctx.fillStyle = themes.default.symbol;
const unitWidth = ctx.measureText('sats').width;
ctx.fillText('Fee ', bounds.x + bounds.w - 48 - feeNumWidth - 6 - unitWidth - 6, secondRowY + 2);
ctx.font = 'bold 32px Roboto';
ctx.fillStyle = themes.default.fg;
ctx.fillText(feeNum, bounds.x + bounds.w - 48 - unitWidth - 6, secondRowY);
ctx.font = '24px Roboto';
ctx.fillStyle = themes.default.symbol;
ctx.fillText('sats', bounds.x + bounds.w - 48, secondRowY + 2);
const statsY = boxY + 42;
const statsX = boxX + boxW / 2;
const formatBytes = (bytes: number): { num: string, unit: string } => {
const units = ['B', 'kB', 'MB', 'GB'];
const base = 1000;
let value = bytes;
let unitIndex = 0;
while (value >= base && unitIndex < units.length - 1) {
value /= base;
unitIndex++;
}
return { num: formatNumber(value, unitIndex === 0 ? '1.0-0' : '1.2-2'), unit: units[unitIndex] };
};
const size = formatBytes(tx.size);
const weight = formatWeightUnit(tx.weight, 2);
ctx.font = 'bold 32px Roboto';
const sizeNumWidth = ctx.measureText(size.num).width;
ctx.font = '24px Roboto';
const sizeUnitWidth = ctx.measureText(` ${size.unit}`).width;
ctx.font = 'bold 32px Roboto';
const weightNumWidth = ctx.measureText(weight.num).width;
ctx.font = '24px Roboto';
const weightUnitWidth = ctx.measureText(` ${weight.unit}`).width;
const spacing = ctx.measureText(' ').width;
const totalWidth = sizeNumWidth + sizeUnitWidth + spacing + weightNumWidth + weightUnitWidth;
let x = statsX - totalWidth / 2;
ctx.font = 'bold 32px Roboto';
ctx.fillStyle = themes.default.fg;
ctx.textAlign = 'left';
ctx.fillText(size.num, x, statsY);
x += sizeNumWidth;
ctx.font = '24px Roboto';
ctx.fillStyle = themes.default.symbol;
ctx.fillText(` ${size.unit}`, x, statsY + 2);
x += sizeUnitWidth + spacing;
ctx.font = 'bold 32px Roboto';
ctx.fillStyle = themes.default.fg;
ctx.fillText(weight.num, x, statsY);
x += weightNumWidth;
ctx.font = '24px Roboto';
ctx.fillStyle = themes.default.symbol;
ctx.fillText(` ${weight.unit}`, x, statsY + 2);
if (!isCoinbase) {
const feeRate = tx.fee / (tx.weight / 4);
const feeRateNum = formatNumber(feeRate, '1.2-2');
ctx.font = 'bold 32px Roboto';
const feeRateNumWidth = ctx.measureText(feeRateNum).width;
ctx.font = '24px Roboto';
const feeRateUnitWidth = ctx.measureText(' sat/vB').width;
const feeRateTotalWidth = feeRateNumWidth + feeRateUnitWidth;
let feeX = statsX - feeRateTotalWidth / 2;
ctx.font = 'bold 32px Roboto';
ctx.fillStyle = themes.default.fg;
ctx.textAlign = 'left';
ctx.fillText(feeRateNum, feeX, statsY + 40);
feeX += feeRateNumWidth;
// Draw fee rate unit
ctx.font = '24px Roboto';
ctx.fillStyle = themes.default.symbol;
ctx.fillText(' sat/vB', feeX, statsY + 40 + 2);
}
}
}
},
txViz: (txid: string, position: Position): Component => {
return {
type: 'tx-viz',
data: [
dataRequirements.tx(txid),
],
render: async (ctx: CanvasRenderingContext2D, data: any): Promise<void> => {
const bounds = { ...position, w: position.w ?? 1200, h: position.h ?? 431 } as Rect;
const tx = data[`tx_${txid}`];
const boxPadding = 10;
ctx.save();
ctx.beginPath();
ctx.rect(
bounds.x - boxPadding,
bounds.y - boxPadding,
bounds.w + (boxPadding * 2),
bounds.h + (boxPadding * 2)
);
ctx.clip();
renderTxBowtie(ctx, tx, bounds, 'default');
ctx.restore();
}
}
},
table: (position: Position, propsCallback?: (data: any, params: any, parentProps: any) => { tableRows: TableRow[] }): Component => ({
type: 'table',
data: [],
@ -389,6 +576,15 @@ export const views: Record<string, (...args: any[]) => Component> = {
components.blockContent(hash, { x: 0, y: 80, w: 1200, h: 520 })
]
}),
tx: (txid: string): Component => ({
type: 'tx',
data: [],
children: [
components.background({ x: 0, y: 0 }),
components.header('Transaction', { x: 0, y: 0 }),
components.txContent(txid, { x: 0, y: 80, w: 1200, h: 520 })
]
}),
address: (address: string): Component => ({
type: 'address',
data: [],

View file

@ -0,0 +1,360 @@
import { CanvasRenderingContext2D } from 'canvas';
import { Rect } from '../components';
import { IEsploraApi } from '../../api/esplora-api.interface';
interface Xput {
type: 'input' | 'output' | 'fee';
value?: number;
index?: number;
rest?: number;
}
interface LineParams {
weight: number;
thickness: number;
offset: number;
innerY: number;
outerY: number;
}
const gradientColors = {
default: ['#9339f4', '#105fb0'],
liquid: ['#09a197', '#0f62af'],
liquidtestnet: ['#d2d2d2', '#979797'],
testnet: ['#4edf77', '#10a0af'],
testnet4: ['#4edf77', '#10a0af'],
signet: ['#d24fc8', '#a84fd2'],
};
export function renderTxBowtie(
ctx: CanvasRenderingContext2D,
tx: IEsploraApi.Transaction,
bounds: Rect,
theme: string
): void {
const width = bounds.w;
const height = bounds.h;
const lineLimit = 250;
const maxCombinedWeight = 100;
const minWeight = 2;
const maxStrands = 24;
const midWidth = Math.min(10, Math.ceil(width / 100));
const txWidth = width - 20;
const combinedWeight = Math.min(maxCombinedWeight, Math.floor((txWidth - (2 * midWidth)) / 6));
const zeroValueWidth = Math.max(20, Math.min((txWidth / 2) - midWidth - 110, 60));
const zeroValueThickness = 20;
const totalValue = calcTotalValue(tx);
let voutWithFee: Xput[] = tx.vout.map((v, i) => ({
type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output',
value: v.value,
index: i,
}));
if (tx.fee) {
voutWithFee.unshift({ type: 'fee', value: tx.fee });
}
let truncatedInputs: Xput[] = tx.vin.map((v, i) => ({
type: 'input',
value: v?.is_coinbase && !totalValue ? 0 : v?.prevout?.value,
index: i,
}));
if (truncatedInputs.length > lineLimit) {
const valueOfRest = truncatedInputs.slice(lineLimit).reduce((r, v) => r + (v.value || 0), 0) || 0;
truncatedInputs = truncatedInputs.slice(0, lineLimit);
truncatedInputs.push({ type: 'input', value: valueOfRest, rest: tx.vin.length - lineLimit });
}
if (voutWithFee.length > lineLimit) {
const valueOfRest = voutWithFee.slice(lineLimit).reduce((r, v) => r + (v.value || 0), 0) || 0;
voutWithFee = voutWithFee.slice(0, lineLimit);
voutWithFee.push({ type: 'output', value: valueOfRest, rest: voutWithFee.length - lineLimit });
}
const inputLines = initLines(truncatedInputs, totalValue, combinedWeight, minWeight, maxStrands, zeroValueThickness, height);
const outputLines = initLines(voutWithFee, totalValue, combinedWeight, minWeight, maxStrands, zeroValueThickness, height);
ctx.save();
ctx.translate(bounds.x, bounds.y);
const outerColor = gradientColors[theme]?.[0] || gradientColors.default[0];
const innerColor = gradientColors[theme]?.[1] || gradientColors.default[1];
inputLines.forEach((line) => {
if (line.zeroValue) {
drawZeroValuePath(ctx, 'in', line.outerY, zeroValueWidth, zeroValueThickness, width, outerColor);
} else {
const connectorWidth = 10;
const markerWidth = Math.max(line.thickness / 2, 8);
const lineStart = connectorWidth + markerWidth;
const lineEnd = width / 2 - midWidth;
const inputGradient = ctx.createLinearGradient(lineStart, 0, lineEnd, 0);
inputGradient.addColorStop(0, outerColor);
inputGradient.addColorStop(1, innerColor);
drawPath(ctx, 'in', line.outerY || 0, line.innerY || 0, line.thickness, line.offset || 0, line.pad || 0, width, midWidth, inputGradient);
drawMarker(ctx, 'in', line.outerY || 0, line.thickness, width, outerColor);
}
});
outputLines.forEach((line, index) => {
if (line.zeroValue) {
drawZeroValuePath(ctx, 'out', line.outerY, zeroValueWidth, zeroValueThickness, width, outerColor);
} else {
const connectorWidth = 10;
const markerWidth = Math.max(line.thickness / 2, 8);
const lineStart = width / 2 + midWidth;
const lineEnd = width - connectorWidth - markerWidth;
const outputGradient = ctx.createLinearGradient(lineStart, 0, lineEnd, 0);
const isFee = voutWithFee[index]?.type === 'fee';
outputGradient.addColorStop(0, innerColor);
outputGradient.addColorStop(1, isFee ? '#181b2d' : outerColor);
drawPath(ctx, 'out', line.outerY || 0, line.innerY || 0, line.thickness, line.offset || 0, line.pad || 0, width, midWidth, outputGradient);
if (!isFee) {
drawMarker(ctx, 'out', line.outerY || 0, line.thickness, width, outerColor);
}
}
});
ctx.strokeStyle = innerColor;
ctx.lineWidth = combinedWeight + 0.5;
ctx.lineCap = 'butt';
ctx.beginPath();
ctx.moveTo((width / 2) - midWidth, (height / 2) + 0.25);
ctx.lineTo((width / 2) + midWidth, (height / 2) + 0.25);
ctx.stroke();
ctx.restore();
}
function calcTotalValue(tx: IEsploraApi.Transaction): number {
const totalOutput = tx.vout.reduce((acc, v) => (v.value || 0) + acc, 0);
return tx.fee ? totalOutput + tx.fee : totalOutput;
}
function initLines(
xputs: Xput[],
total: number,
combinedWeight: number,
minWeight: number,
maxVisibleStrands: number,
zeroValueThickness: number,
height: number
): Array<LineParams & { zeroValue?: boolean; pad?: number }> {
if (!total) {
const weights = xputs.map(() => combinedWeight / xputs.length);
return linesFromWeights(xputs, weights, combinedWeight, minWeight, maxVisibleStrands, zeroValueThickness, height);
} else {
let unknownCount = 0;
let unknownTotal = total;
xputs.forEach(put => {
if (put.value == null) {
unknownCount++;
} else {
unknownTotal -= put.value;
}
});
const unknownShare = unknownTotal / unknownCount;
const weights = xputs.map((put) => combinedWeight * (put.value == null ? unknownShare : put.value) / total);
return linesFromWeights(xputs, weights, combinedWeight, minWeight, maxVisibleStrands, zeroValueThickness, height);
}
}
function linesFromWeights(
xputs: Xput[],
weights: number[],
combinedWeight: number,
minWeight: number,
maxVisibleStrands: number,
zeroValueThickness: number,
height: number
): Array<LineParams & { zeroValue?: boolean; pad?: number }> {
const lineParams: Array<LineParams & { zeroValue?: boolean; pad?: number }> = weights.map((w, i) => ({
weight: w,
thickness: xputs[i].value === 0 ? zeroValueThickness : Math.min(combinedWeight + 0.5, Math.max(minWeight - 1, w) + 1),
offset: 0,
innerY: 0,
outerY: 0,
zeroValue: xputs[i].value === 0,
}));
const visibleStrands = Math.min(maxVisibleStrands, xputs.length);
const visibleWeight = lineParams.slice(0, visibleStrands).reduce((acc, v) => v.thickness + acc, 0);
const gaps = visibleStrands - 1;
const innerTop = (height / 2) - (combinedWeight / 2);
const innerBottom = innerTop + combinedWeight + 0.5;
let lastOuter = 0;
let lastInner = innerTop;
const spacing = Math.max(4, (height - visibleWeight) / gaps);
let offset = 0;
let minOffset = 0;
let maxOffset = 0;
let lastWeight = 0;
let pad = 0;
lineParams.forEach((line, i) => {
if (xputs[i].value === 0) {
line.outerY = lastOuter + (zeroValueThickness / 2);
if (xputs.length === 1) {
line.outerY = (height / 2);
}
lastOuter += zeroValueThickness + spacing;
return;
}
line.outerY = lastOuter + (line.thickness / 2);
line.innerY = Math.min(innerBottom - (line.thickness / 2), Math.max(innerTop + (line.thickness / 2), lastInner + (line.weight / 2)));
if (xputs.length === 1) {
line.outerY = (height / 2);
}
lastOuter += line.thickness + spacing;
lastInner += line.weight;
if (!xputs[i].rest) {
const w = (maxVisibleStrands - Math.max(lastWeight, line.weight)) / 2;
const y1 = line.outerY;
const y2 = line.innerY;
const t = (lastWeight + line.weight) / 2;
const dx = 0.75 * w;
const dy = 1.5 * (y2 - y1);
const a = Math.atan2(dy, dx);
if (Math.sin(a) !== 0) {
offset += Math.max(Math.min(t * (1 - Math.cos(a)) / Math.sin(a), t), -t);
}
line.offset = offset;
minOffset = Math.min(minOffset, offset);
maxOffset = Math.max(maxOffset, offset);
pad = Math.max(pad, line.thickness / 2);
lastWeight = line.weight;
}
});
lineParams.forEach((line) => {
line.offset -= minOffset;
line.pad = pad + (maxOffset - minOffset);
});
return lineParams;
}
function drawPath(
ctx: CanvasRenderingContext2D,
side: 'in' | 'out',
outer: number,
inner: number,
weight: number,
offset: number,
pad: number,
width: number,
midWidth: number,
gradient: CanvasGradient
): void {
const connectorWidth = 10;
const start = (weight * 0.5) + connectorWidth;
const curveStart = Math.max(start + 5, pad + connectorWidth - offset);
const end = width / 2 - (midWidth * 0.9) + 1;
const curveEnd = end - offset - 10;
const midpoint = (curveStart + curveEnd) / 2;
let adjustedOuter = outer;
if (Math.round(outer) === Math.round(inner)) {
adjustedOuter -= 1;
}
ctx.strokeStyle = gradient;
ctx.lineWidth = weight;
ctx.lineCap = 'butt';
ctx.beginPath();
if (side === 'in') {
ctx.moveTo(start, adjustedOuter);
ctx.lineTo(curveStart, adjustedOuter);
ctx.bezierCurveTo(midpoint, adjustedOuter, midpoint, inner, curveEnd, inner);
ctx.lineTo(end, inner);
} else {
ctx.moveTo(width - start, adjustedOuter);
ctx.lineTo(width - curveStart, adjustedOuter);
ctx.bezierCurveTo(width - midpoint, adjustedOuter, width - midpoint, inner, width - curveEnd, inner);
ctx.lineTo(width - end, inner);
}
ctx.stroke();
}
function drawZeroValuePath(
ctx: CanvasRenderingContext2D,
side: 'in' | 'out',
y: number,
zeroValueWidth: number,
zeroValueThickness: number,
width: number,
color: string
): void {
const offset = zeroValueThickness / 2;
const start = 15;
ctx.strokeStyle = color;
ctx.lineWidth = zeroValueThickness;
ctx.lineCap = 'round';
ctx.beginPath();
if (side === 'in') {
ctx.moveTo(start + offset, y);
ctx.lineTo(start + zeroValueWidth + offset, y);
} else {
ctx.moveTo(width - start - offset, y);
ctx.lineTo(width - start - zeroValueWidth - offset, y);
}
ctx.stroke();
}
function drawMarker(
ctx: CanvasRenderingContext2D,
side: 'in' | 'out',
y: number,
thickness: number,
width: number,
color: string
): void {
const halfThickness = thickness / 2;
const markerWidth = Math.max(halfThickness, 8);
const connectorWidth = 10;
const overlap = 1;
ctx.fillStyle = color;
if (side === 'in') {
const x = connectorWidth + overlap;
ctx.beginPath();
ctx.moveTo(x, y - halfThickness);
ctx.lineTo(x + markerWidth, y - halfThickness);
ctx.lineTo(x + markerWidth, y + halfThickness);
ctx.lineTo(x, y + halfThickness);
ctx.lineTo(x, y + halfThickness);
ctx.lineTo(x + markerWidth, y);
ctx.lineTo(x, y - halfThickness);
ctx.closePath();
ctx.fill('evenodd');
} else {
const x = width - connectorWidth - overlap;
ctx.beginPath();
ctx.moveTo(x - markerWidth, y - halfThickness);
ctx.lineTo(x, y);
ctx.lineTo(x - markerWidth, y + halfThickness);
ctx.closePath();
ctx.fill();
}
}

View file

@ -161,6 +161,7 @@ const routes = {
}
}
},
canvasView: 'tx',
routes: {
push: {
title: "Push Transaction",