import * as React from 'react'; import { ListInline, Loading, RefreshButton, shortHash } from './Utils'; import * as d3 from 'd3'; import { JsonBlock } from 'casper-client-sdk'; import { ToggleButton, ToggleStore } from './ToggleButton'; import { autorun } from 'mobx'; import * as d3xyzoom from 'd3-xyzoom'; import { observer } from 'mobx-react'; // https://bl.ocks.org/mapio/53fed7d84cd1812d6a6639ed7aa83868 const CircleRadius = 12; const LineColor = '#AAA'; const FinalizedLineColor = '#83f2a1'; const OrphanedLineColor = '#FF0000'; export interface Props { title: string; refresh?: () => void; subscribeToggleStore?: ToggleStore; hideBallotsToggleStore?: ToggleStore; hideBlockHashToggleStore?: ToggleStore; blocks: JsonBlock[] | null; emptyMessage?: any; footerMessage?: any; width: string | number; height: string | number; selected?: JsonBlock; depth: number; onDepthChange?: (depth: number) => void; onSelected?: (block: JsonBlock) => void; } @observer export class BlockDAG extends React.Component<Props, {}> { svg: SVGSVGElement | null = null; hint: HTMLDivElement | null = null; xTrans: d3.ScaleLinear<number, number> | null = null; yTrans: d3.ScaleLinear<number, number> | null = null; initialized = false; constructor(props: Props) { super(props); autorun( () => { this.renderGraph(); }, { delay: 400 } ); } render() { return ( <div className="card mb-3"> <div className="card-header"> <span>{this.props.title}</span> <div className="float-right"> <ListInline> {this.props.hideBallotsToggleStore && ( <ToggleButton label="Hide Ballots" title="Hide Ballots" toggleStore={this.props.hideBallotsToggleStore} size="sm" /> )} {this.props.subscribeToggleStore && ( <ToggleButton title="Subscribe to the latest added blocks" label="Live Feed" toggleStore={this.props.subscribeToggleStore} size="sm" /> )} {this.props.hideBlockHashToggleStore && ( <ToggleButton title="Hide BlockHash Label" label="Hide Label" toggleStore={this.props.hideBlockHashToggleStore} size="sm" /> )} {this.props.onDepthChange && ( <select title="Depth" value={this.props.depth.toString()} onChange={e => this.props.onDepthChange!(Number(e.target.value)) } > {[10, 20, 50, 100].map(x => ( <option key={x} value={x}> {x} </option> ))} </select> )} {this.props.refresh && ( <RefreshButton refresh={() => this.props.refresh!()} /> )} </ListInline> </div> </div> <div className="card-body"> {this.props.blocks == null ? ( <Loading /> ) : this.props.blocks.length === 0 ? ( <div className="small text-muted"> {this.props.emptyMessage || 'No blocks to show.'} </div> ) : ( <div className="svg-container"> <svg width={this.props.width} height={this.props.height} ref={(ref: SVGSVGElement) => (this.svg = ref)} ></svg> <div className="svg-hint" ref={(ref: HTMLDivElement) => (this.hint = ref)} ></div> </div> )} </div> {this.props.footerMessage && ( <div className="card-footer small text-muted"> {this.props.footerMessage} </div> )} </div> ); } /** Called so that the SVG is added when the component has been rendered, * however data will most likely still be uninitialized. */ componentDidMount() { this.renderGraph(); } renderGraph() { const hideBallot = this.props.hideBallotsToggleStore?.isPressed; const hideBlockHash = this.props.hideBlockHashToggleStore?.isPressed; let blocks = this.props.blocks; if (blocks == null || blocks.length === 0) { // The renderer will have removed the svg. this.initialized = false; return; } const svg = d3.select(this.svg); const hint = d3.select(this.hint); const validatorColor = consistentColor(d3.schemePaired); const eraColor = consistentColor(d3.schemeCategory10); // See what the actual width and height is. const width = $(this.svg!).width()!; const height = $(this.svg!).height()!; // see https://www.d3-graph-gallery.com/graph/interactivity_zoom.html#axisZoom // using axis transform function to simplify transform let initXTrans: d3.ScaleLinear<number, number> = d3 .scaleLinear() .domain([0, width]) .range([0, width]); let initYTrans: d3.ScaleLinear<number, number> = d3 .scaleLinear() .domain([0, height]) .range([0, height]); // Append items that will not change. if (!this.initialized) { this.xTrans = initXTrans; this.yTrans = initYTrans; // Add the zoomable container. svg.append('g').attr('class', 'container'); // whether it is under horizontal only zoom mode let isHorizontal = false; const zoom = d3xyzoom .xyzoom() .extent([ [0, 0], [width, height] ]) .scaleExtent([ [0.4, 4], [0.4, 4] ]) .on('start', () => { // The first wheel event emits a start event; an end event is emitted when no wheel events are received for 150ms. // So if user press ctrlKey when fire first wheel event, // it enters the horizontal only zoom mode. if (d3.event.sourceEvent && d3.event.sourceEvent.type === 'wheel') { isHorizontal = d3.event.sourceEvent.ctrlKey; if (isHorizontal) { svg.attr('cursor', 'ew-resize'); } else { svg.attr('cursor', 'nesw-resize'); } } else if ( d3.event.sourceEvent && d3.event.sourceEvent.type === 'mousedown' ) { svg.attr('cursor', 'grab'); } }) .on('zoom', () => { let t = d3.event.transform; this.xTrans = t.rescaleX(initXTrans); if (!isHorizontal) { this.yTrans = t.rescaleY(initYTrans); } // updatePositions(); }) .on('end', () => { // reset isHorizontal = false; // set null to remove the attribute svg.attr('cursor', null); // see https://github.com/d3/d3-zoom/issues/48 // Stored the current axis of x and y, and set the transform to be the identity transform, // where kx = 1, ky = 1, tx = ty = 0, so when switching between zoom mode, // both of them won't interleave. initXTrans = this.xTrans ?? initXTrans; initYTrans = this.yTrans ?? initYTrans; svg.property('__zoom', d3xyzoom.xyzoomIdentity); }); svg.call(zoom); // add the defs of arrow which can be used to draw an arrow at the end of the lines to point at parents later svg .append('svg:defs') .append('svg:marker') .attr('id', 'arrow') .attr('refX', 6) .attr('refY', 6) .attr('markerWidth', 10) .attr('markerHeight', 10) .attr('orient', 'auto') .append('path') .attr('d', 'M 3 4 7 6 3 8') .attr('fill', LineColor); this.initialized = true; } // The container are the root layer to draw all node/label/line components const container = svg.select('.container'); // Clear previous contents. container.selectAll('g').remove(); let graph: Graph = calculateCoordinates(toGraph(blocks), width, height); const selectedId = this.props.selected && this.props.selected.hash; const link = container .append('g') .attr('class', 'links') .selectAll('line') .data( graph.links.filter((d: d3Link) => { if (hideBallot) { // return isBlock(d.source.block) && isBlock(d.target.block); return true; } else { return true; } }) ) .enter() .append('line') .attr('stroke', (d: d3Link) => d.isOrphaned ? OrphanedLineColor : d.isFinalized ? FinalizedLineColor : LineColor ) .attr('stroke-width', (d: d3Link) => (d.isMainParent ? 3 : 1)) .attr('marker-end', 'url(#arrow)') // use the Arrow created above .attr('stroke-dasharray', (d: d3Link) => d.isJustification ? '3, 3' : null ) .attr('opacity', (d: d3Link) => (d.isJustification ? 0 : 1)); const node = container .append('g') .attr('class', 'nodes') .selectAll('g') .data( graph.nodes.filter(node => { if (hideBallot) { // return isBlock(node.block); return true; } else { return true; } }) ) .enter() .append('g'); node .append('circle') .attr('class', 'node') .attr('r', (d: d3Node) => { // fixme // return CircleRadius * (isBallot(d.block) ? 0.5 : 1.0); return CircleRadius; }) .attr('stroke', (d: d3Node) => selectedId && d.id === selectedId ? '#E00' : eraColor(d.eraId.toString()) ) .attr('stroke-width', (d: d3Node) => selectedId && d.id === selectedId ? '7px' : '4px' ) .attr('fill', (d: d3Node) => validatorColor(d.validator)); // Append a node-label to each node const label = node .append('text') .attr('class', 'node-label') .text((d: d3Node) => d.title) .style('font-family', 'Arial') .style('font-size', 12) .style('pointer-events', 'none') // to prevent mouseover/drag capture .style('text-anchor', 'start') .attr('display', hideBlockHash ? 'none' : 'block'); const focus = (d: any) => { let datum = d3.select(d3.event.target).datum() as d3Node; node.style('opacity', x => graph.areNeighbours(x.id, datum.id) ? 1 : 0.1 ); label.attr('display', x => graph.areNeighbours(x.id, datum.id) ? 'block' : 'none' ); link.style('opacity', x => x.source.id === datum.id || x.target.id === datum.id ? 1 : x.isJustification ? 0 : 0.1 ); hint.html( `Block: ${datum.id} @ ${datum.rank} <br /> Validator: ${datum.validator}` ); hint.style('display', 'block'); }; const unfocus = () => { label.attr('display', hideBlockHash ? 'none' : 'block'); node.style('opacity', 1); link.style('opacity', x => (x.isJustification ? 0 : 1)); hint.style('display', 'none'); }; const select = (d: any) => { let datum = d3.select(d3.event.target).datum() as d3Node; this.props.onSelected && this.props.onSelected(datum.block); }; node.on('mouseover', focus).on('mouseout', unfocus); node.on('click', select); /* COMMENTING OUT AS UNUSED AND IS PRODUCING ERRORS const updatePositions = () => { const x = this.xTrans ?? initXTrans; const y = this.yTrans ?? initYTrans; // update position of node container .selectAll('circle.node') .attr('cx', (d: any) => x(d.x)) .attr('cy', (d: any) => y(d.y)); // update position of label container .selectAll('text.node-label') .attr('x', (d: any) => x(d.x) + 5) .attr('y', (d: any) => y(d.y) + 25) .attr('transform', (d: any) => `rotate(25 ${x(d.x)} ${y(d.y)})`); // rotate so a chain doesn't overlap on a small screen. // update positions of line container .selectAll('line') .attr('x1', (d: any) => x(d.source.x)) .attr('y1', (d: any) => y(d.source.y)) .attr('x2', (d: any) => { // We want the radius of Node keep the same after scaling, so we need use invert function to calculate that before scaling. const newRInX = x.invert(CircleRadius + 2) - x.invert(0); return x( d.source.x + (d.target.x - d.source.x) * shorten(d, newRInX) ); }) .attr('y2', (d: any) => { // We want the radius of Node keep the same after scaling, so we need use invert function to calculate that before scaling. const newRInY = y.invert(CircleRadius + 2) - y.invert(0); return y( d.source.y + (d.target.y - d.source.y) * shorten(d, newRInY) ); }); }; updatePositions(); */ } } interface d3Node { id: string; title: string; validator: string; eraId: number; rank: number; x?: number; y?: number; block: JsonBlock; } interface d3Link { source: d3Node; target: d3Node; isMainParent: boolean; isJustification: boolean; isFinalized: boolean; isOrphaned: boolean; } class Graph { private targets: Map<String, Set<String>> = new Map(); constructor(public nodes: d3Node[], public links: d3Link[]) { links.forEach(link => { let targets = this.targets.get(link.source.id) || new Set<String>(); targets.add(link.target.id); this.targets.set(link.source.id, targets); }); } hasTarget = (from: string, to: string) => this.targets.has(from) && this.targets.get(from)!.has(to); areNeighbours = (a: string, b: string) => a === b || this.hasTarget(a, b) || this.hasTarget(b, a); } /** Turn blocks into the reduced graph structure. */ const toGraph = (blocks: JsonBlock[]) => { let nodes: d3Node[] = blocks.map(block => { return { id: block.hash, title: shortHash(block.hash), validator: block.header.proposer, eraId: block.header.era_id, rank: block.header.height, block: block }; }); let nodeMap = new Map(nodes.map(x => [x.id, x])); let links = blocks.flatMap(block => { let child = block.hash; let isChildFinalized = isFinalized(block); let isChildOrphaned = isOrphaned(block); let isChildBallot = isBallot(block); // fixme // let parents = block // .getSummary()! // .getHeader()! // .getParentHashesList_asU8() // .map(h => encodeBase16(h)); let parents: JsonBlock[] = []; let parentSet = new Set(parents); // fixme // let justifications = block // .getSummary()! // .getHeader()! // .getJustificationsList() // .map(x => encodeBase16(x.getLatestBlockHash_asU8())); let justifications: JsonBlock[] = []; let source = nodeMap.get(child)!; let parentLinks = parents .filter(p => nodeMap.has(p.hash)) .map(p => { let target = nodeMap.get(p.hash)!; return { source: source, target: target, isMainParent: p === parents[0], isJustification: false, isFinalized: (isChildFinalized || isChildBallot) && isFinalized(target.block), // if child is an orphaned block, the link should be highlighted with OrphanedLineColor isOrphaned: isChildOrphaned }; }); let justificationLinks = justifications .filter(x => !parentSet.has(x)) .filter(j => nodeMap.has(j.hash)) .map(j => { let target = nodeMap.get(j.hash)!; return { source: source, target: target, isMainParent: false, isJustification: true, isFinalized: (isChildFinalized || isChildBallot) && isFinalized(target.block), isOrphaned: false }; }); return parentLinks.concat(justificationLinks); }); return new Graph(nodes, links); }; /** Calculate coordinates so that valiators are in horizontal swimlanes, time flowing left to right. */ const calculateCoordinates = (graph: Graph, width: number, height: number) => { const validators = [...new Set(graph.nodes.map(x => x.validator))].sort(); const marginPercent = 0.4; // so that there are space between swimlanes const verticalStep = height / validators.length; const maxRank = Math.max(...graph.nodes.map(x => x.rank)); const minRank = Math.min(...graph.nodes.map(x => x.rank)); const horizontalStep = width / (maxRank - minRank + 2); // count how many nodes having the same (validator, rank) let countOfRanks = new Map<string, number>(); // current index, for the specified key, curIndexOfRanks[key] = [0, countOfRanks[key]) let curIndexOfRanks = new Map<string, number>(); // since JavaScript doesn't support tuple as key of Map, we need to encode the primary keys let key = (node: d3Node) => `${node.validator},${node.rank}`; graph.nodes.forEach(node => { let k = key(node); if (countOfRanks.has(k)) { countOfRanks.set(k, countOfRanks.get(k)! + 1); } else { countOfRanks.set(k, 1); } }); graph.nodes.forEach(node => { let k = key(node); let count = countOfRanks.get(k)!; let step = 0; if (count !== 1) { let index = curIndexOfRanks.has(k) ? curIndexOfRanks.get(k)! : 0; // for each node i of nodes NS having the same (validator, rank), c is the size of NS // its distance from the baseline of swimlane is (i / (c - 1) - 0.5) * height of swimlane // the height of swimlane is (1 - marginPercent) * verticalStep step = (index / (count - 1) - 0.5) * (1 - marginPercent); curIndexOfRanks.set(k, index + 1); } // (validators.indexOf(node.validator) + 0.5) * verticalStep is the y of the baseline of swimlane of node.validator node.y = (validators.indexOf(node.validator) + 0.5 + step) * verticalStep; node.x = (node.rank - minRank + 1) * horizontalStep; }); return graph; }; // fixme const isBlock = (block: JsonBlock) => true; // block.getSummary()!.getHeader()!.getMessageType() === Block.MessageType.BLOCK; // fixme const isBallot = (block: JsonBlock) => !isBlock(block); // fixme const isFinalized = (block: JsonBlock) => true; // block.getStatus()!.getFinality() === BlockInfo.Status.Finality.FINALIZED; // fixme const isOrphaned = (block: JsonBlock) => false; // isBlock(block) && // block.getStatus()!.getFinality() === BlockInfo.Status.Finality.ORPHANED; /** Shorten lines by a fixed amount so that the line doesn't stick out from under the arrows tip. */ // const shorten = (d: any, by: number) => { // let length = Math.sqrt( // Math.pow(d.target.x - d.source.x, 2) + Math.pow(d.target.y - d.source.y, 2) // ); // return Math.max(0, (length - by) / length); // }; /** String hash for consistent colors. Same as Java. */ const hashCode = (s: string) => { let hash = 0; if (s.length === 0) return hash; for (let i = 0; i < s.length; i++) { let chr = s.charCodeAt(i); hash = (hash << 5) - hash + chr; hash |= 0; // Convert to 32bit integer } return hash; }; const consistentColor = (colors: readonly string[]) => { // Display each validator with its own color. // https://www.d3-graph-gallery.com/graph/custom_color.html // http://bl.ocks.org/curran/3094b37e63b918bab0a06787e161607b // This can be used like `color(x.validator)` but it changes depending on which validators are on the screen. // const color = d3.scaleOrdinal(d3.schemeCategory10); // This can be used with a numeric value: // const hashRange: [number, number] = [-2147483648, 2147483647]; // d3.scaleSequential(d3.interpolateSpectral).domain(hashRange), const cl = colors.length; return (s: string) => { const h = hashCode(s); const c = Math.abs(h % cl); return colors[c]; }; };