mirror of
https://github.com/mempool/mempool.git
synced 2026-08-13 12:33:11 +02:00
Merge pull request #6107 from mempool/mononaut/canvas-unfurl-tx-preview
Unfurler canvas: add tx component
This commit is contained in:
commit
ce3179b018
3 changed files with 558 additions and 1 deletions
|
|
@ -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: [],
|
||||
|
|
|
|||
360
unfurler/src/canvas/tx-bowtie/tx-bowtie.ts
Normal file
360
unfurler/src/canvas/tx-bowtie/tx-bowtie.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -161,6 +161,7 @@ const routes = {
|
|||
}
|
||||
}
|
||||
},
|
||||
canvasView: 'tx',
|
||||
routes: {
|
||||
push: {
|
||||
title: "Push Transaction",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue