| import type { MIRBlock, LIRBlock, LIRInstruction, MIRInstruction, Pass, SampleCounts, BlockID, BlockPtr, InsPtr, InsID } from "./iongraph.js"; |
| import { tweak } from "./tweak.js"; |
| import { assert, clamp, filerp, must } from "./utils.js"; |
| |
| const DEBUG = tweak("Debug?", 0, { min: 0, max: 1 }); |
| |
| const CONTENT_PADDING = 20; |
| const BLOCK_GAP = 44; |
| const PORT_START = 16; |
| const PORT_SPACING = 60; |
| const ARROW_RADIUS = 12; |
| const TRACK_PADDING = 36; |
| const JOINT_SPACING = 16; |
| const HEADER_ARROW_PUSHDOWN = 16; |
| const BACKEDGE_GAP = 40; |
| |
| const LAYOUT_ITERATIONS = tweak("Layout Iterations", 2, { min: 0, max: 6 }); |
| const NEARLY_STRAIGHT = tweak("Nearly Straight Threshold", 30, { min: 0, max: 200 }); |
| const NEARLY_STRAIGHT_ITERATIONS = tweak("Nearly Straight Iterations", 8, { min: 0, max: 10 }); |
| const STOP_AT_PASS = tweak("Stop At Pass", 30, { min: 0, max: 30 }); |
| |
| const ZOOM_SENSITIVITY = 1.50; |
| const WHEEL_DELTA_SCALE = 0.01; |
| const MAX_ZOOM = 1; |
| const MIN_ZOOM = 0.10; |
| const TRANSLATION_CLAMP_AMOUNT = 40; |
| |
| export interface Vec2 { |
| x: number, |
| y: number, |
| } |
| |
| type Block = { |
| ptr: BlockPtr, |
| id: BlockID, |
| mir: MIRBlock, |
| lir: LIRBlock | null, |
| |
| preds: Block[], |
| succs: Block[], |
| el: HTMLElement, |
| size: Vec2, |
| layer: number, |
| layoutNode: LayoutNode, // this is set partway through the process but trying to type it as such is absolutely not worth it |
| |
| isLoopHeader: boolean, |
| isBackedge: boolean, |
| isRoot: boolean, |
| isOSRTarget: boolean, |
| isCatchEntrypoint: boolean, |
| } |
| |
| type LayoutNode = BlockNode | DummyNode; |
| |
| type LayoutNodeID = number & { readonly __brand: "LayoutNodeID" }; |
| |
| interface _LayoutNodeCommon { |
| id: LayoutNodeID, |
| pos: Vec2, |
| size: Vec2, |
| srcNodes: LayoutNode[], |
| dstNodes: LayoutNode[], |
| jointOffsets: number[], |
| flags: NodeFlags, |
| } |
| |
| type BlockNode = _LayoutNodeCommon & { |
| block: Block, |
| }; |
| |
| type DummyNode = _LayoutNodeCommon & { |
| block: null, |
| dstBlock: Block, |
| }; |
| |
| type NodeFlags = number; |
| const LEFTMOST_DUMMY: NodeFlags = 1 << 0; |
| const RIGHTMOST_DUMMY: NodeFlags = 1 << 1; |
| const IMMINENT_BACKEDGE_DUMMY: NodeFlags = 1 << 2; |
| |
| export const SC_TOTAL = 0; |
| export const SC_SELF = 1; |
| |
| const log = new Proxy(console, { |
| get(target, prop: keyof Console) { |
| const field = target[prop]; |
| |
| if (typeof field !== "function") { // catches undefined too |
| return field; |
| } |
| return +DEBUG ? field.bind(target) : () => { }; |
| } |
| }); |
| |
| export interface GraphNavigation { |
| /** Chain of blocks visited by navigating up and down */ |
| visited: BlockPtr[], |
| |
| /** Current index into {@link visited} */ |
| currentIndex: number, |
| |
| /** Current set of sibling blocks to navigate sideways */ |
| siblings: BlockPtr[], |
| } |
| |
| export interface HighlightedInstruction { |
| ptr: InsPtr, |
| paletteColor: number, |
| } |
| |
| export interface GraphState { |
| translation: Graph["translation"], |
| zoom: Graph["zoom"], |
| heatmapMode: Graph["heatmapMode"], |
| highlightedInstructions: Graph["highlightedInstructions"], |
| selectedBlockPtrs: Graph["selectedBlockPtrs"], |
| lastSelectedBlockPtr: Graph["lastSelectedBlockPtr"], |
| |
| viewportPosOfSelectedBlock: Vec2 | undefined, |
| } |
| |
| export interface RestoreStateOpts { |
| preserveSelectedBlockPosition?: boolean, |
| } |
| |
| export interface GraphOptions { |
| /** |
| * Sample counts to display when viewing the LIR graph. |
| */ |
| sampleCounts?: SampleCounts, |
| |
| /** |
| * An array of CSS colors to use for highlighting instructions. You are |
| * encouraged to use CSS variables here. |
| */ |
| instructionPalette?: string[], |
| } |
| |
| export class Graph { |
| // |
| // HTML elements |
| // |
| viewport: HTMLElement |
| viewportSize: Vec2; |
| graphContainer: HTMLElement; |
| |
| // |
| // Core iongraph data |
| // |
| blocks: Block[]; |
| blocksByID: Map<BlockID, Block>; |
| blocksByPtr: Map<BlockPtr, Block>; |
| insPtrsByID: Map<InsID, InsPtr>; |
| insIDsByPtr: Map<InsPtr, InsID>; |
| |
| sampleCounts: SampleCounts | undefined; |
| maxSampleCounts: [number, number]; // [total, self] |
| heatmapMode: number; // SC_TOTAL or SC_SELF |
| |
| // |
| // Post-layout info |
| // |
| size: Vec2; |
| numLayers: number; |
| |
| // |
| // Pan and zoom |
| // |
| zoom: number; |
| translation: Vec2; |
| |
| animating: boolean; |
| targetZoom: number; |
| targetTranslation: Readonly<Vec2>; |
| |
| startMousePos: Readonly<Vec2>; |
| lastMousePos: Readonly<Vec2>; |
| |
| // |
| // Block and instruction selection / navigation |
| // |
| selectedBlockPtrs: Set<BlockPtr>; |
| lastSelectedBlockPtr: BlockPtr; // 0 is treated as a null value |
| nav: GraphNavigation; |
| |
| highlightedInstructions: HighlightedInstruction[]; |
| instructionPalette: string[]; |
| |
| // Layout stabilization (two-pass layout approach) |
| stabilizationTimeout: number | null = null; |
| layoutFinalized: boolean = false; |
| |
| // Deferred position restoration |
| deferredJumpToBlock: { blockPtr: BlockPtr, opts: { zoom?: number, animate?: boolean, viewportPos?: Vec2 } } | null = null; |
| |
| constructor(viewport: HTMLElement, pass: Pass, options: GraphOptions = {}) { |
| this.viewport = viewport; |
| const viewportRect = viewport.getBoundingClientRect(); |
| this.viewportSize = { |
| x: viewportRect.width, |
| y: viewportRect.height, |
| }; |
| |
| this.graphContainer = document.createElement("div"); |
| this.graphContainer.classList.add("ig-graph"); |
| this.graphContainer.style.transformOrigin = "top left"; |
| this.viewport.appendChild(this.graphContainer); |
| |
| this.sampleCounts = options.sampleCounts; |
| this.maxSampleCounts = [0, 0]; |
| this.heatmapMode = SC_TOTAL; |
| |
| for (const [ins, count] of this.sampleCounts?.totalLineHits ?? []) { |
| this.maxSampleCounts[SC_TOTAL] = Math.max(this.maxSampleCounts[SC_TOTAL], count); |
| } |
| for (const [ins, count] of this.sampleCounts?.selfLineHits ?? []) { |
| this.maxSampleCounts[SC_SELF] = Math.max(this.maxSampleCounts[SC_SELF], count); |
| } |
| |
| this.size = { x: 0, y: 0 }; |
| this.numLayers = 0; |
| |
| this.zoom = 1; |
| this.translation = { x: 0, y: 0 }; |
| |
| this.animating = false; |
| this.targetZoom = 1; |
| this.targetTranslation = { x: 0, y: 0 }; |
| |
| this.startMousePos = { x: 0, y: 0 }; |
| this.lastMousePos = { x: 0, y: 0 }; |
| |
| this.selectedBlockPtrs = new Set(); |
| this.lastSelectedBlockPtr = 0 as BlockPtr; |
| this.nav = { |
| visited: [], |
| currentIndex: -1, |
| siblings: [], |
| }; |
| |
| this.highlightedInstructions = []; |
| this.instructionPalette = options.instructionPalette ?? [0, 1, 2, 3, 4].map(n => `var(--ig-highlight-${n})`); |
| |
| this.blocks = pass.mir.blocks.map(m => { |
| const block: Block = { |
| ptr: m.ptr, |
| id: m.id, |
| mir: m, |
| lir: pass.lir.blocks.find(l => l.id === m.id) ?? null, |
| |
| preds: [], |
| succs: [], |
| el: undefined as unknown as HTMLElement, // set below in constructor |
| size: { x: 0, y: 0 }, |
| layer: -1, |
| layoutNode: undefined as unknown as LayoutNode, // set in makeLayoutNodes |
| isLoopHeader: false, |
| isBackedge: false, |
| isRoot: false, |
| isOSRTarget: false, |
| isCatchEntrypoint: false, |
| }; |
| |
| assert(block.ptr, "blocks must always have non-null ptrs"); |
| return block; |
| }); |
| this.blocksByID = new Map(); |
| this.blocksByPtr = new Map(); |
| this.insPtrsByID = new Map(); |
| this.insIDsByPtr = new Map(); |
| |
| // Initialize maps |
| for (const block of this.blocks) { |
| this.blocksByID.set(block.id, block); |
| this.blocksByPtr.set(block.ptr, block); |
| for (const ins of block.mir.instructions) { |
| // TODO: This is kind of jank because it will overwrite MIR |
| // instructions that were also there. But we never render those, so |
| // it's basically moot. |
| this.insPtrsByID.set(ins.id, ins.ptr); |
| this.insIDsByPtr.set(ins.ptr, ins.id); |
| } |
| |
| if (block.lir) { |
| for (const ins of block.lir.instructions) { |
| this.insPtrsByID.set(ins.id, ins.ptr); |
| this.insIDsByPtr.set(ins.ptr, ins.id); |
| } |
| } |
| } |
| |
| // After putting all blocks in our maps, fill out block-to-block references. |
| for (const block of this.blocks) { |
| block.preds = block.mir.predecessors.map(id => must(this.blocksByID.get(id))); |
| block.succs = block.mir.successors.map(id => must(this.blocksByID.get(id))); |
| block.isRoot = block.preds.length === 0; |
| block.isOSRTarget = block.mir.attributes.includes("osr"); |
| block.isCatchEntrypoint = block.mir.attributes.includes("catch"); |
| } |
| |
| // Compute layers and detect cycles before creating elements |
| // This ensures isLoopHeader/isBackedge flags are set before renderBlock() |
| this.assignLayers(); |
| |
| // Create and measure all blocks |
| for (const block of this.blocks) { |
| block.el = this.renderBlock(block); |
| } |
| |
| // Compute sizes for all blocks. We do this after rendering all blocks so |
| // that layout isn't constantly invalidated by adding new blocks. |
| // |
| // (Although, it's super bullshit that we have to do this, because all of |
| // the blocks are absolutely positioned and therefore have zero impact on |
| // any others. Adding another block to the page should not invalidate all |
| // the layout properties of all the others! We should not see an 85% |
| // speedup just from moving this out of the first loop, but we do!) |
| for (const block of this.blocks) { |
| block.size = { |
| x: block.el.offsetWidth, |
| y: block.el.offsetHeight, |
| }; |
| } |
| |
| // Run initial layout |
| this.runLayout(); |
| |
| // Schedule a single re-layout after tables settle (100ms debounce) |
| // This catches any late size adjustments from table layout |
| // Because WebKit / blink does multi-pass layout for tables, the layout |
| // can change after the first layout. So we continue doing re-layout until |
| // the result converges. |
| this.stabilizationTimeout = window.setTimeout(() => { |
| this.stabilizationTimeout = null; |
| |
| // Re-measure all blocks |
| let changed = false; |
| for (const block of this.blocks) { |
| const w = block.el.offsetWidth; |
| const h = block.el.offsetHeight; |
| if (Math.abs(block.size.x - w) > 1 || Math.abs(block.size.y - h) > 1) { |
| block.size = { x: w, y: h }; |
| changed = true; |
| } |
| } |
| if (changed) { |
| this.runLayout(); |
| } |
| this.layoutFinalized = true; |
| log.log("Graph layout finalized."); |
| }, 100); |
| |
| this.addEventListeners(); |
| } |
| |
| private runLayout() { |
| const [nodesByLayer, layerHeights, trackHeights] = this.layout(); |
| |
| const children = Array.from(this.graphContainer.children); |
| for (const child of children) { |
| if (!child.classList.contains("ig-block")) { |
| child.remove(); |
| } |
| } |
| |
| this.render(nodesByLayer, layerHeights, trackHeights); |
| |
| // Execute deferred position restoration after layout |
| if (this.deferredJumpToBlock) { |
| const { blockPtr, opts } = this.deferredJumpToBlock; |
| this.deferredJumpToBlock = null; |
| this.jumpToBlock(blockPtr, opts); |
| } |
| } |
| |
| private layout(): [LayoutNode[][], number[], number[]] { |
| const layoutNodesByLayer = this.makeLayoutNodes(); |
| |
| this.straightenEdges(layoutNodesByLayer); |
| const trackHeights = this.finagleJoints(layoutNodesByLayer); |
| const layerHeights = this.verticalize(layoutNodesByLayer, trackHeights); |
| |
| return [layoutNodesByLayer, layerHeights, trackHeights]; |
| } |
| |
| private findLayoutRoots(): [Block[], Block[]] { |
| const newRoots: Block[] = []; |
| const osrBlocks: Block[] = []; |
| const roots = this.blocks.filter(b => b.preds.length === 0); |
| |
| for (const root of roots) { |
| newRoots.push(root); |
| if (root.mir.attributes.includes("osr")) { |
| osrBlocks.push(root); |
| } |
| } |
| if (newRoots.length === 0 && this.blocks.length > 0) { |
| const sorted = [...this.blocks].sort((a,b) => a.id - b.id); |
| newRoots.push(sorted[0]); |
| } |
| return [newRoots, osrBlocks]; |
| } |
| |
| private detectCycles(): Map<Block, Set<Block>> { |
| const visited = new Map<Block, number>(); |
| const isBackedge = new Map<Block, Set<Block>>(); |
| |
| const detect = (u: Block) => { |
| visited.set(u, 1); |
| for (const v of u.succs) { |
| const status = visited.get(v) ?? 0; |
| if (status === 1) { |
| let s = isBackedge.get(u); |
| if (!s) { s = new Set(); isBackedge.set(u, s); } |
| s.add(v); |
| u.isBackedge = true; |
| v.isLoopHeader = true; |
| } else if (status === 0) { |
| detect(v); |
| } |
| } |
| visited.set(u, 2); |
| }; |
| |
| for (const b of this.blocks) { |
| if (!visited.has(b)) detect(b); |
| } |
| |
| return isBackedge; |
| } |
| |
| private assignLayers() { |
| const isBackedge = this.detectCycles(); |
| |
| for (const b of this.blocks) b.layer = -1; |
| |
| const computeLayer = (u: Block): number => { |
| if (u.layer !== -1) return u.layer; |
| |
| let maxParentLayer = -1; |
| for (const p of u.preds) { |
| const pBackedges = isBackedge.get(p); |
| if (pBackedges && pBackedges.has(u)) continue; |
| |
| const pl = computeLayer(p); |
| if (pl > maxParentLayer) maxParentLayer = pl; |
| } |
| |
| u.layer = maxParentLayer + 1; |
| return u.layer; |
| }; |
| |
| let maxLayer = 0; |
| for (const b of this.blocks) { |
| const l = computeLayer(b); |
| if (l > maxLayer) maxLayer = l; |
| } |
| this.numLayers = maxLayer + 1; |
| log.log(`Assigned generic layers. Max layer: ${maxLayer}`); |
| } |
| |
| private makeLayoutNodes(): LayoutNode[][] { |
| log.group("makeLayoutNodes"); |
| |
| function connectNodes(from: LayoutNode, fromPort: number, to: LayoutNode) { |
| from.dstNodes[fromPort] = to; |
| if (!to.srcNodes.includes(from)) { |
| to.srcNodes.push(from); |
| } |
| } |
| |
| let blocksByLayer: Block[][]; |
| { |
| const blocksByLayerObj: { [layer: number]: Block[] } = {}; |
| for (const block of this.blocks) { |
| const l = block.layer < 0 ? 0 : block.layer; |
| if (!blocksByLayerObj[l]) { |
| blocksByLayerObj[l] = []; |
| } |
| blocksByLayerObj[l].push(block); |
| } |
| blocksByLayer = Object.entries(blocksByLayerObj) |
| .map(([layer, blocks]) => [Number(layer), blocks] as const) |
| .sort((a, b) => a[0] - b[0]) |
| .map(([_, blocks]) => blocks); |
| } |
| |
| const denseBlocksByLayer: Block[][] = []; |
| for(let i=0; i<this.numLayers; i++) { |
| denseBlocksByLayer[i] = blocksByLayer.find(b => b[0]?.layer === i) || []; |
| } |
| |
| type IncompleteEdge = { |
| src: LayoutNode, |
| srcPort: number, |
| dstBlock: Block, |
| }; |
| |
| let nodeID = 0 as LayoutNodeID; |
| const layoutNodesByLayer: LayoutNode[][] = denseBlocksByLayer.map(() => []); |
| const activeForwardEdges: IncompleteEdge[] = []; |
| |
| for (const [layer, blocks] of denseBlocksByLayer.entries()) { |
| const terminatingEdges: IncompleteEdge[] = []; |
| for (const block of blocks) { |
| for (let i = activeForwardEdges.length - 1; i >= 0; i--) { |
| const edge = activeForwardEdges[i]; |
| if (edge.dstBlock === block) { |
| terminatingEdges.unshift(edge); |
| activeForwardEdges.splice(i, 1); |
| } |
| } |
| } |
| |
| const dummiesByDest: Map<number, DummyNode> = new Map(); |
| for (const edge of activeForwardEdges) { |
| let dummy = dummiesByDest.get(edge.dstBlock.id); |
| if (dummy) { |
| connectNodes(edge.src, edge.srcPort, dummy); |
| } else { |
| dummy = { |
| id: nodeID++ as LayoutNodeID, |
| pos: { x: CONTENT_PADDING, y: CONTENT_PADDING }, |
| size: { x: 0, y: 0 }, |
| block: null, |
| srcNodes: [], |
| dstNodes: [], |
| dstBlock: edge.dstBlock, |
| jointOffsets: [], |
| flags: 0, |
| }; |
| connectNodes(edge.src, edge.srcPort, dummy); |
| layoutNodesByLayer[layer].push(dummy); |
| dummiesByDest.set(edge.dstBlock.id, dummy); |
| } |
| edge.src = dummy; |
| edge.srcPort = 0; |
| } |
| |
| for (const block of blocks) { |
| const node: BlockNode = { |
| id: nodeID++ as LayoutNodeID, |
| pos: { x: CONTENT_PADDING, y: CONTENT_PADDING }, |
| size: block.size, |
| block: block, |
| srcNodes: [], |
| dstNodes: [], |
| jointOffsets: [], |
| flags: 0, |
| }; |
| block.layoutNode = node; |
| layoutNodesByLayer[layer].push(node); |
| |
| for (const edge of terminatingEdges) { |
| if (edge.dstBlock === block) { |
| connectNodes(edge.src, edge.srcPort, node); |
| } |
| } |
| } |
| |
| for (const block of blocks) { |
| block.succs.forEach((succ, portIndex) => { |
| if (succ.layer > layer) { |
| if (succ.layer === layer + 1) { |
| activeForwardEdges.push({ src: block.layoutNode, srcPort: portIndex, dstBlock: succ }); |
| } else { |
| activeForwardEdges.push({ src: block.layoutNode, srcPort: portIndex, dstBlock: succ }); |
| } |
| } else { |
| // Backedge or same-layer edge (Cycle): Route via dummy chain to the right |
| let currentLayer = layer; |
| let targetLayer = succ.layer; |
| |
| let prevNode: LayoutNode = block.layoutNode; |
| let prevPort = portIndex; |
| |
| for (let l = currentLayer; l >= targetLayer; l--) { |
| const dummy: DummyNode = { |
| id: nodeID++ as LayoutNodeID, |
| pos: { x: CONTENT_PADDING, y: CONTENT_PADDING }, |
| size: { x: 0, y: 0 }, |
| block: null, |
| srcNodes: [], |
| dstNodes: [], |
| dstBlock: succ, |
| jointOffsets: [], |
| flags: IMMINENT_BACKEDGE_DUMMY, |
| }; |
| |
| layoutNodesByLayer[l].push(dummy); |
| |
| connectNodes(prevNode, prevPort, dummy); |
| |
| prevNode = dummy; |
| prevPort = 0; |
| } |
| |
| if (succ.layoutNode) { |
| connectNodes(prevNode, prevPort, succ.layoutNode); |
| } |
| } |
| }); |
| } |
| } |
| |
| // Mark dummies |
| for (const nodes of layoutNodesByLayer) { |
| for (let i = 0; i < nodes.length; i++) { |
| if (nodes[i].block === null) { |
| nodes[i].flags |= LEFTMOST_DUMMY; |
| } else { |
| break; |
| } |
| } |
| for (let i = nodes.length - 1; i >= 0; i--) { |
| if (nodes[i].block === null) { |
| nodes[i].flags |= RIGHTMOST_DUMMY; |
| } else { |
| break; |
| } |
| } |
| } |
| |
| log.groupEnd(); |
| return layoutNodesByLayer; |
| } |
| |
| private straightenEdges(layoutNodesByLayer: LayoutNode[][]) { |
| // Push nodes to the right if they are too close together. |
| const pushNeighbors = (nodes: LayoutNode[]) => { |
| for (let i = 0; i < nodes.length - 1; i++) { |
| const node = nodes[i]; |
| const neighbor = nodes[i + 1]; |
| |
| const firstNonDummy = node.block === null && neighbor.block !== null; |
| const nodeRightPlusPadding = node.pos.x + node.size.x + (firstNonDummy ? PORT_START : 0) + BLOCK_GAP; |
| neighbor.pos.x = Math.max(neighbor.pos.x, nodeRightPlusPadding); |
| } |
| }; |
| |
| const straightenDummyRuns = () => { |
| // Track max position of dummies |
| const dummyLinePositions = new Map<Block, number>(); |
| for (const dummy of dummies(layoutNodesByLayer)) { |
| const dst = dummy.dstBlock; |
| let desiredX = dummy.pos.x; |
| dummyLinePositions.set(dst, Math.max(dummyLinePositions.get(dst) ?? 0, desiredX)); |
| } |
| |
| // Apply positions to dummies |
| for (const dummy of dummies(layoutNodesByLayer)) { |
| const backedge = dummy.dstBlock; |
| const x = dummyLinePositions.get(backedge); |
| if(x) dummy.pos.x = x; |
| } |
| for (const nodes of layoutNodesByLayer) { |
| pushNeighbors(nodes); |
| } |
| }; |
| |
| const suckInLeftmostDummies = () => { |
| // Break leftmost dummy runs by pulling them as far right as possible |
| // (but never pulling any node to the right of its parent, or its |
| // ultimate destination block). Track the min position for each |
| // destination as we go. |
| const dummyRunPositions = new Map<Block, number>(); |
| for (const nodes of layoutNodesByLayer) { |
| // Find leftmost non-dummy node |
| let i = 0; |
| let nextX = 0; |
| for (; i < nodes.length; i++) { |
| if (!(nodes[i].flags & LEFTMOST_DUMMY)) { |
| nextX = nodes[i].pos.x; |
| break; |
| } |
| } |
| |
| // Walk backward through leftmost dummies, calculating how far to the |
| // right we can push them. |
| i -= 1; |
| nextX -= BLOCK_GAP + PORT_START; |
| for (; i >= 0; i--) { |
| const dummy = nodes[i] as DummyNode; |
| let maxSafeX = nextX; |
| // Don't let dummies go to the right of their source nodes. |
| for (const src of dummy.srcNodes) { |
| const srcX = src.pos.x + src.dstNodes.indexOf(dummy) * PORT_SPACING; |
| if (srcX < maxSafeX) { |
| maxSafeX = srcX; |
| } |
| } |
| dummy.pos.x = maxSafeX; |
| nextX = dummy.pos.x - BLOCK_GAP; |
| dummyRunPositions.set(dummy.dstBlock, Math.min(dummyRunPositions.get(dummy.dstBlock) ?? Infinity, maxSafeX)); |
| } |
| } |
| |
| // Apply min positions to all dummies in a run. |
| for (const dummy of dummies(layoutNodesByLayer)) { |
| if (!(dummy.flags & LEFTMOST_DUMMY)) continue; |
| const x = dummyRunPositions.get(dummy.dstBlock); |
| if(x) dummy.pos.x = x; |
| } |
| }; |
| |
| // Walk down the layers, pulling children to the right to line up with |
| // their parents. |
| const straightenChildren = () => { |
| for (let layer = 0; layer < layoutNodesByLayer.length - 1; layer++) { |
| const nodes = layoutNodesByLayer[layer]; |
| pushNeighbors(nodes); |
| |
| // If a node has been shifted, we must never shift any node to its |
| // left. This preserves stable graph layout and just avoids lots of |
| // jank. We also only shift a child based on its first parent, because |
| // otherwide nodes end up being pulled too far to the right. |
| let lastShifted = -1; |
| for (const node of nodes) { |
| for (const [srcPort, dst] of node.dstNodes.entries()) { |
| if (!layoutNodesByLayer[layer + 1].includes(dst)) continue; |
| |
| let dstIndexInNextLayer = layoutNodesByLayer[layer + 1].indexOf(dst); |
| if (dstIndexInNextLayer > lastShifted && dst.srcNodes[0] === node) { |
| const srcPortOffset = PORT_START + PORT_SPACING * srcPort; |
| const dstPortOffset = PORT_START; |
| let xBefore = dst.pos.x; |
| dst.pos.x = Math.max(dst.pos.x, node.pos.x + srcPortOffset - dstPortOffset); |
| if (dst.pos.x !== xBefore) { |
| lastShifted = dstIndexInNextLayer; |
| } |
| } |
| } |
| } |
| } |
| }; |
| |
| // Walk each layer right to left, pulling nodes to the right to line them |
| // up with their parents and children as well as possible, but WITHOUT ever |
| // causing another overlap and therefore any need to push neighbors. |
| // |
| // (The exception is rightmost dummies; we push those because we can |
| // trivially straighten them later.) |
| const straightenConservative = () => { |
| for (const nodes of layoutNodesByLayer) { |
| for (let i = nodes.length - 1; i >= 0; i--) { |
| const node = nodes[i]; |
| if (!node.block || node.block.isLoopHeader) continue; |
| |
| let deltasToTry: number[] = []; |
| for (const parent of node.srcNodes) { |
| const srcPortOffset = PORT_START + parent.dstNodes.indexOf(node) * PORT_SPACING; |
| const dstPortOffset = PORT_START; |
| deltasToTry.push((parent.pos.x + srcPortOffset) - (node.pos.x + dstPortOffset)); |
| } |
| for (const [srcPort, dst] of node.dstNodes.entries()) { |
| if (dst.block === null && dst.dstBlock.isLoopHeader) continue; |
| const srcPortOffset = PORT_START + srcPort * PORT_SPACING; |
| const dstPortOffset = PORT_START; |
| deltasToTry.push((dst.pos.x + dstPortOffset) - (node.pos.x + srcPortOffset)); |
| } |
| if (deltasToTry.includes(0)) continue; |
| deltasToTry = deltasToTry.filter(d => d > 0).sort((a, b) => a - b); |
| |
| for (const delta of deltasToTry) { |
| let overlapsAny = false; |
| for (let j = i + 1; j < nodes.length; j++) { |
| const other = nodes[j]; |
| if (other.flags & RIGHTMOST_DUMMY) continue; |
| const a1 = node.pos.x + delta, a2 = node.pos.x + delta + node.size.x; |
| const b1 = other.pos.x - BLOCK_GAP, b2 = other.pos.x + other.size.x + BLOCK_GAP; |
| const overlaps = a2 >= b1 && a1 <= b2; |
| if (overlaps) overlapsAny = true; |
| } |
| if (!overlapsAny) { |
| node.pos.x += delta; |
| break; |
| } |
| } |
| } |
| pushNeighbors(nodes); |
| } |
| }; |
| |
| // Walk up the layers, straightening out edges that are nearly straight. |
| const straightenNearlyStraightEdgesUp = () => { |
| for (let layer = layoutNodesByLayer.length - 1; layer >= 0; layer--) { |
| const nodes = layoutNodesByLayer[layer]; |
| pushNeighbors(nodes); |
| for (const node of nodes) { |
| for (const src of node.srcNodes) { |
| if (src.block !== null) continue; |
| const wiggle = Math.abs(src.pos.x - node.pos.x); |
| if (wiggle <= NEARLY_STRAIGHT) { |
| src.pos.x = Math.max(src.pos.x, node.pos.x); |
| node.pos.x = Math.max(src.pos.x, node.pos.x); |
| } |
| } |
| } |
| } |
| }; |
| |
| // Ditto, but walking down instead of up. |
| const straightenNearlyStraightEdgesDown = () => { |
| for (let layer = 0; layer < layoutNodesByLayer.length; layer++) { |
| const nodes = layoutNodesByLayer[layer]; |
| pushNeighbors(nodes); |
| for (const node of nodes) { |
| if (node.dstNodes.length === 0) continue; |
| const dst = node.dstNodes[0]; |
| if (dst.block !== null) continue; |
| const wiggle = Math.abs(dst.pos.x - node.pos.x); |
| if (wiggle <= NEARLY_STRAIGHT) { |
| dst.pos.x = Math.max(dst.pos.x, node.pos.x); |
| node.pos.x = Math.max(dst.pos.x, node.pos.x); |
| } |
| } |
| } |
| }; |
| |
| function repeat<T>(a: T[], n: number): T[] { |
| const result: T[] = []; |
| for (let i = 0; i < n; i++) { |
| for (const item of a) result.push(item); |
| } |
| return result; |
| } |
| |
| // The order of these passes is arbitrary. I just play with it until I like |
| // the result. I have them in this wacky structure because I want to be |
| // able to use my debug scrubber |
| const passes = [ |
| ...repeat([ |
| straightenChildren, |
| straightenDummyRuns, |
| ], LAYOUT_ITERATIONS), |
| straightenDummyRuns, |
| ...repeat([ |
| straightenNearlyStraightEdgesUp, |
| straightenNearlyStraightEdgesDown, |
| ], NEARLY_STRAIGHT_ITERATIONS), |
| straightenConservative, |
| straightenDummyRuns, |
| suckInLeftmostDummies, |
| ]; |
| |
| for (const [i, pass] of passes.entries()) { |
| if (i < STOP_AT_PASS) { |
| pass(); |
| } |
| } |
| } |
| |
| private finagleJoints(layoutNodesByLayer: LayoutNode[][]): number[] { |
| interface Joint { |
| x1: number, |
| x2: number, |
| src: LayoutNode, |
| srcPort: number, |
| dst: LayoutNode, |
| } |
| |
| const trackHeights: number[] = []; |
| |
| for (const nodes of layoutNodesByLayer) { |
| // Get all joints into a list, and sort them left to right by their |
| // starting coordinate. This produces the nicest visual nesting. |
| const joints: Joint[] = []; |
| for (const node of nodes) { |
| node.jointOffsets = new Array(node.dstNodes.length).fill(0); |
| |
| for (const [srcPort, dst] of node.dstNodes.entries()) { |
| if (dst.pos.y < node.pos.y) continue; |
| |
| const x1 = node.pos.x + PORT_START + PORT_SPACING * srcPort; |
| const x2 = dst.pos.x + PORT_START; |
| if (Math.abs(x2 - x1) < 2 * ARROW_RADIUS) continue; |
| joints.push({ x1, x2, src: node, srcPort, dst }); |
| } |
| } |
| joints.sort((a, b) => a.x1 - b.x1); |
| |
| // Greedily sort joints into "tracks" based on whether they overlap |
| // horizontally with each other. We walk the tracks from the outside in |
| // and place the joint in the innermost possible track, stopping if we |
| // ever overlap with any other joint. |
| const rightwardTracks: Joint[][] = []; |
| const leftwardTracks: Joint[][] = []; |
| nextJoint: |
| for (const joint of joints) { |
| const trackSet = joint.x2 - joint.x1 >= 0 ? rightwardTracks : leftwardTracks; |
| let lastValidTrack: Joint[] | null = null; |
| for (let i = trackSet.length - 1; i >= 0; i--) { |
| const track = trackSet[i]; |
| let overlapsWithAnyInThisTrack = false; |
| for (const otherJoint of track) { |
| if (joint.dst === otherJoint.dst) { |
| // Assign the joint to this track to merge arrows |
| track.push(joint); |
| continue nextJoint; |
| } |
| |
| const al = Math.min(joint.x1, joint.x2), ar = Math.max(joint.x1, joint.x2); |
| const bl = Math.min(otherJoint.x1, otherJoint.x2), br = Math.max(otherJoint.x1, otherJoint.x2); |
| const overlaps = ar >= bl && al <= br; |
| if (overlaps) { |
| overlapsWithAnyInThisTrack = true; |
| break; |
| } |
| } |
| |
| if (overlapsWithAnyInThisTrack) { |
| break; |
| } else { |
| lastValidTrack = track; |
| } |
| } |
| |
| if (lastValidTrack) { |
| lastValidTrack.push(joint); |
| } else { |
| trackSet.push([joint]); |
| } |
| } |
| |
| // Use track info to apply joint offsets to nodes for rendering. |
| // We |
| const tracksHeight = Math.max(0, rightwardTracks.length + leftwardTracks.length - 1) * JOINT_SPACING; |
| let trackOffset = -tracksHeight / 2; |
| for (const track of [...rightwardTracks.reverse(), ...leftwardTracks]) { |
| for (const joint of track) { |
| joint.src.jointOffsets[joint.srcPort] = trackOffset; |
| } |
| trackOffset += JOINT_SPACING; |
| } |
| |
| trackHeights.push(tracksHeight); |
| } |
| |
| return trackHeights; |
| } |
| |
| private verticalize(layoutNodesByLayer: LayoutNode[][], trackHeights: number[]): number[] { |
| const layerHeights: number[] = new Array(layoutNodesByLayer.length); |
| |
| let nextLayerY = CONTENT_PADDING; |
| for (let i = 0; i < layoutNodesByLayer.length; i++) { |
| const nodes = layoutNodesByLayer[i]; |
| |
| let layerHeight = 0; |
| for (const node of nodes) { |
| node.pos.y = nextLayerY; |
| layerHeight = Math.max(layerHeight, node.size.y); |
| } |
| |
| layerHeights[i] = layerHeight; |
| nextLayerY += layerHeight + TRACK_PADDING + trackHeights[i] + TRACK_PADDING; |
| } |
| |
| return layerHeights; |
| } |
| |
| private renderBlock(block: Block): HTMLElement { |
| const el = document.createElement("div"); |
| this.graphContainer.appendChild(el); |
| el.classList.add("ig-block", "ig-bg-white"); |
| for (const att of block.mir.attributes) { |
| el.classList.add(`ig-block-att-${att}`); |
| } |
| el.setAttribute("data-ig-block-ptr", `${block.ptr}`); |
| el.setAttribute("data-ig-block-id", `${block.id}`); |
| |
| let desc : String[] = []; |
| |
| if (block.isRoot) { |
| desc.push("(root)"); |
| el.classList.add(`ig-block-att-root`); |
| } |
| |
| if (block.isCatchEntrypoint) { |
| desc.push("(catch)"); |
| } |
| |
| if (block.isOSRTarget) { |
| desc.push("(osr)"); |
| } |
| |
| if (block.isLoopHeader) { |
| desc.push("(loop header)"); |
| el.classList.add(`ig-block-att-loopheader`); |
| } |
| |
| if (block.isBackedge) { |
| desc.push("(backedge)"); |
| el.classList.add(`ig-block-att-backedge`); |
| } |
| |
| const header = document.createElement("div"); |
| header.classList.add("ig-block-header"); |
| header.innerText = `Block ${block.id}${desc.length === 0 ? '' : ' ' + desc.join(' ')}`; |
| el.appendChild(header); |
| |
| const insnsContainer = document.createElement("div"); |
| insnsContainer.classList.add("ig-instructions"); |
| el.appendChild(insnsContainer); |
| |
| const insns = document.createElement("table"); |
| if (block.lir) { |
| insns.innerHTML = ` |
| <colgroup> |
| <col style="width: 1px"> |
| <col style="width: auto"> |
| ${this.sampleCounts ? ` |
| <col style="width: 1px"> |
| <col style="width: 1px"> |
| ` : ""} |
| </colgroup> |
| ${this.sampleCounts ? ` |
| <thead> |
| <tr> |
| <th></th> |
| <th></th> |
| <th class="ig-f6">Total</th> |
| <th class="ig-f6">Self</th> |
| </tr> |
| </thead> |
| ` : ""} |
| `; |
| for (const ins of block.lir.instructions) { |
| insns.appendChild(this.renderLIRInstruction(ins)); |
| } |
| } else { |
| insns.innerHTML = ` |
| <colgroup> |
| <col style="width: 1px"> |
| <col style="width: auto"> |
| <col style="width: 1px"> |
| </colgroup> |
| `; |
| for (const ins of block.mir.instructions) { |
| insns.appendChild(this.renderMIRInstruction(ins)); |
| } |
| } |
| insnsContainer.appendChild(insns); |
| |
| // Show edge labels with target block numbers |
| for (const [i, succ] of block.succs.entries()) { |
| const edgeLabel = document.createElement("div"); |
| edgeLabel.innerText = `#${succ.id}`; |
| edgeLabel.classList.add("ig-edge-label"); |
| edgeLabel.style.left = `${PORT_START + PORT_SPACING * i}px`; |
| el.appendChild(edgeLabel); |
| } |
| |
| // Attach event handlers |
| header.addEventListener("pointerdown", e => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| header.addEventListener("click", e => { |
| e.stopPropagation(); |
| if (!e.shiftKey) { |
| this.selectedBlockPtrs.clear(); |
| } |
| this.setSelection([], block.ptr); |
| }); |
| |
| return el; |
| } |
| |
| private render(nodesByLayer: LayoutNode[][], layerHeights: number[], trackHeights: number[]) { |
| // Position blocks according to layout |
| for (const nodes of nodesByLayer) { |
| for (const node of nodes) { |
| if (node.block !== null) { |
| const block = node.block; |
| block.el.style.left = `${node.pos.x}px`; |
| block.el.style.top = `${node.pos.y}px`; |
| } |
| } |
| } |
| |
| // Create and size the SVG |
| let maxX = 0, maxY = 0; |
| for (const nodes of nodesByLayer) { |
| for (const node of nodes) { |
| maxX = Math.max(maxX, node.pos.x + node.size.x + CONTENT_PADDING); |
| maxY = Math.max(maxY, node.pos.y + node.size.y + CONTENT_PADDING); |
| } |
| } |
| const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| this.graphContainer.appendChild(svg); |
| |
| const trackPositions = (xs: number[], ys: number[]) => { |
| for (const x of xs) maxX = Math.max(maxX, x + CONTENT_PADDING); |
| for (const y of ys) maxY = Math.max(maxY, y + CONTENT_PADDING); |
| }; |
| |
| // Render arrows |
| for (let layer = 0; layer < nodesByLayer.length; layer++) { |
| const nodes = nodesByLayer[layer]; |
| for (const node of nodes) { |
| assert(node.dstNodes.length === node.jointOffsets.length, "must have a joint offset for each destination"); |
| |
| for (const [i, dst] of node.dstNodes.entries()) { |
| const x1 = node.pos.x + PORT_START + PORT_SPACING * i; |
| let y1 = node.pos.y + node.size.y; |
| const x2 = dst.pos.x + PORT_START; |
| let y2 = dst.pos.y; |
| |
| const isUpward = y2 < y1; |
| const isBackedgeDummyChain = (n: LayoutNode) => (n.flags & IMMINENT_BACKEDGE_DUMMY) !== 0; |
| |
| if (node.block && dst.block === null && isUpward) { |
| // Block -> Dummy (Backedge start) |
| const ym = y1 + TRACK_PADDING; |
| const arrow = arrowFromBlockToBackedgeDummy(x1, y1, x2, y2, ym); |
| svg.appendChild(arrow); |
| trackPositions([x1, x2], [y1, y2, ym]); |
| } else if (node.block === null && dst.block !== null && Math.abs(y1 - y2) < 1) { |
| // Dummy -> Block (Backedge end) |
| // Go up, then left, then down to block |
| const yHigh = y2 - BACKEDGE_GAP; |
| const arrow = arrowBackedgeEnd(x1, y1, x2, y2, yHigh); |
| svg.appendChild(arrow); |
| trackPositions([x1, x2], [y1, y2, yHigh]); |
| } else if (isUpward) { |
| // Dummy -> Dummy |
| const ym = y1 - TRACK_PADDING; |
| const arrow = upwardArrow(x1, y1, x2, y2, ym, dst.block !== null); |
| svg.appendChild(arrow); |
| trackPositions([x1, x2], [y1, y2, ym]); |
| } else { |
| // Forward Edge |
| const ym = (y1 - node.size.y) + layerHeights[layer] + TRACK_PADDING + trackHeights[layer] / 2 + node.jointOffsets[i]; |
| const arrow = downwardArrow(x1, y1, x2, y2, ym, dst.block !== null); |
| svg.appendChild(arrow); |
| trackPositions([x1, x2], [y1, y2, ym]); |
| } |
| } |
| } |
| } |
| |
| svg.setAttribute("width", `${maxX}`); |
| svg.setAttribute("height", `${maxY}`); |
| this.size = { x: maxX, y: maxY }; |
| |
| // Render debug nodes |
| if (+DEBUG) { |
| for (const nodes of nodesByLayer) { |
| for (const node of nodes) { |
| const el = document.createElement("div"); |
| // Build debug info safely using DOM APIs |
| el.appendChild(document.createTextNode(String(node.id))); |
| el.appendChild(document.createElement("br")); |
| el.appendChild(document.createTextNode(`L:${node.block?.layer}`)); |
| el.appendChild(document.createElement("br")); |
| el.appendChild(document.createTextNode(`<- ${node.srcNodes.map(n => n.id)}`)); |
| el.appendChild(document.createElement("br")); |
| el.appendChild(document.createTextNode(`-> ${node.dstNodes.map(n => n.id)}`)); |
| el.style.position = "absolute"; |
| el.style.border = "1px solid black"; |
| el.style.backgroundColor = "white"; |
| el.style.left = `${node.pos.x}px`; |
| el.style.top = `${node.pos.y}px`; |
| el.style.whiteSpace = "nowrap"; |
| this.graphContainer.appendChild(el); |
| } |
| } |
| } |
| |
| // Final rendering of other effects |
| this.updateHighlightedInstructions(); |
| this.updateHotness(); |
| } |
| |
| private renderMIRInstruction(ins: MIRInstruction): HTMLElement { |
| const prettyOpcode = ins.opcode |
| .replace('->', '→') |
| .replace('<-', '←'); |
| |
| const row = document.createElement("tr"); |
| row.classList.add( |
| "ig-ins", "ig-ins-mir", "ig-can-flash", |
| ...ins.attributes.map(att => `ig-ins-att-${att}`), |
| ); |
| row.setAttribute("data-ig-ins-ptr", `${ins.ptr}`); |
| row.setAttribute("data-ig-ins-id", `${ins.id}`); |
| |
| const num = document.createElement("td"); |
| num.classList.add("ig-ins-num"); |
| num.innerText = String(ins.id); |
| row.appendChild(num); |
| |
| const opcode = document.createElement("td"); |
| // Build opcode content safely using DOM APIs instead of innerHTML |
| // Pattern: name#id should become clickable spans |
| const usePattern = /([A-Za-z0-9_<>]+)#(\d+)/g; |
| let lastIndex = 0; |
| let match; |
| |
| while ((match = usePattern.exec(prettyOpcode)) !== null) { |
| // Add text before the match |
| if (match.index > lastIndex) { |
| const textBefore = prettyOpcode.substring(lastIndex, match.index); |
| opcode.appendChild(document.createTextNode(textBefore)); |
| } |
| |
| // Create span for the use reference |
| const useSpan = document.createElement("span"); |
| useSpan.className = "ig-use ig-highlightable"; |
| useSpan.setAttribute("data-ig-use", encodeURIComponent(match[2])); |
| useSpan.textContent = `${match[1]}#${match[2]}`; |
| opcode.appendChild(useSpan); |
| |
| lastIndex = usePattern.lastIndex; |
| } |
| |
| // Add remaining text after last match |
| if (lastIndex < prettyOpcode.length) { |
| const textAfter = prettyOpcode.substring(lastIndex); |
| opcode.appendChild(document.createTextNode(textAfter)); |
| } |
| |
| row.appendChild(opcode); |
| |
| const type = document.createElement("td"); |
| type.classList.add("ig-ins-type"); |
| type.innerText = ins.type === "None" ? "" : ins.type; |
| row.appendChild(type); |
| |
| // Event listeners |
| num.addEventListener("pointerdown", e => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| num.addEventListener("click", () => { |
| this.toggleInstructionHighlight(ins.ptr); |
| }); |
| |
| opcode.querySelectorAll<HTMLElement>(".ig-use").forEach(use => { |
| use.addEventListener("pointerdown", e => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| use.addEventListener("click", e => { |
| const id = parseInt(must(use.getAttribute("data-ig-use")), 10) as InsID; |
| this.jumpToInstruction(id, { zoom: 1 }); |
| }); |
| }); |
| |
| return row; |
| } |
| |
| private renderLIRInstruction(ins: LIRInstruction): HTMLElement { |
| const prettyOpcode = ins.opcode |
| .replace('->', '→') |
| .replace('<-', '←'); |
| |
| const row = document.createElement("tr"); |
| row.classList.add("ig-ins", "ig-ins-lir", "ig-hotness"); |
| row.setAttribute("data-ig-ins-ptr", `${ins.ptr}`); |
| row.setAttribute("data-ig-ins-id", `${ins.id}`); |
| |
| const num = document.createElement("td"); |
| num.classList.add("ig-ins-num"); |
| num.innerText = String(ins.id); |
| row.appendChild(num); |
| |
| const opcode = document.createElement("td"); |
| opcode.innerText = prettyOpcode; |
| row.appendChild(opcode); |
| |
| if (this.sampleCounts) { |
| const totalSampleCount = this.sampleCounts?.totalLineHits.get(ins.id) ?? 0; |
| const selfSampleCount = this.sampleCounts?.selfLineHits.get(ins.id) ?? 0; |
| |
| const totalSamples = document.createElement("td"); |
| totalSamples.classList.add("ig-ins-samples"); |
| totalSamples.classList.toggle("ig-text-dim", totalSampleCount === 0); |
| totalSamples.innerText = `${totalSampleCount}`; |
| totalSamples.title = "Color by total count"; |
| row.appendChild(totalSamples); |
| |
| const selfSamples = document.createElement("td"); |
| selfSamples.classList.add("ig-ins-samples"); |
| selfSamples.classList.toggle("ig-text-dim", selfSampleCount === 0); |
| selfSamples.innerText = `${selfSampleCount}`; |
| selfSamples.title = "Color by self count"; |
| row.appendChild(selfSamples); |
| |
| // Event listeners |
| for (const [i, el] of [totalSamples, selfSamples].entries()) { |
| el.addEventListener("pointerdown", e => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| el.addEventListener("click", () => { |
| assert(i === SC_TOTAL || i === SC_SELF); |
| this.heatmapMode = i; |
| this.updateHotness(); |
| }); |
| } |
| } |
| |
| // Event listeners |
| num.addEventListener("pointerdown", e => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| }); |
| num.addEventListener("click", () => { |
| this.toggleInstructionHighlight(ins.ptr); |
| }); |
| |
| return row; |
| } |
| |
| private renderSelection() { |
| this.graphContainer.querySelectorAll(".ig-block").forEach(blockEl => { |
| const ptr = parseInt(must(blockEl.getAttribute("data-ig-block-ptr")), 10) as BlockPtr; |
| blockEl.classList.toggle("ig-selected", this.selectedBlockPtrs.has(ptr)); |
| blockEl.classList.toggle("ig-last-selected", this.lastSelectedBlockPtr === ptr); |
| }); |
| } |
| |
| private removeNonexistentHighlights() { |
| this.highlightedInstructions = this.highlightedInstructions.filter(hi => { |
| return this.graphContainer.querySelector<HTMLElement>(`.ig-ins[data-ig-ins-ptr="${hi.ptr}"]`); |
| }); |
| } |
| |
| private updateHighlightedInstructions() { |
| for (const hi of this.highlightedInstructions) { |
| assert(this.highlightedInstructions.filter(other => other.ptr === hi.ptr).length === 1, `instruction ${hi.ptr} was highlighted more than once`); |
| } |
| |
| // Clear all existing highlight styles |
| this.graphContainer.querySelectorAll<HTMLElement>(".ig-ins, .ig-use").forEach(ins => { |
| clearHighlight(ins); |
| }); |
| |
| // Highlight all instructions |
| for (const hi of this.highlightedInstructions) { |
| const color = this.instructionPalette[hi.paletteColor % this.instructionPalette.length]; |
| const row = this.graphContainer.querySelector<HTMLElement>(`.ig-ins[data-ig-ins-ptr="${hi.ptr}"]`); |
| if (row) { |
| highlight(row, color); |
| |
| const id = this.insIDsByPtr.get(hi.ptr); |
| this.graphContainer.querySelectorAll<HTMLElement>(`.ig-use[data-ig-use="${id}"]`).forEach(use => { |
| highlight(use, color); |
| }); |
| } |
| } |
| } |
| |
| private updateHotness() { |
| this.graphContainer.querySelectorAll<HTMLElement>(".ig-ins-lir").forEach(insEl => { |
| assert(insEl.classList.contains("ig-hotness")); |
| const insID = parseInt(must(insEl.getAttribute("data-ig-ins-id")), 10); |
| let hotness = 0; |
| if (this.sampleCounts) { |
| const counts = this.heatmapMode === SC_TOTAL ? this.sampleCounts.totalLineHits : this.sampleCounts.selfLineHits; |
| hotness = (counts.get(insID) ?? 0) / this.maxSampleCounts[this.heatmapMode]; |
| } |
| insEl.style.setProperty("--ig-hotness", `${hotness}`); |
| }); |
| } |
| |
| private addEventListeners() { |
| this.viewport.addEventListener("wheel", e => { |
| e.preventDefault(); |
| |
| let newZoom = this.zoom; |
| if (e.ctrlKey) { |
| newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * Math.pow(ZOOM_SENSITIVITY, -e.deltaY * WHEEL_DELTA_SCALE))); |
| const zoomDelta = (newZoom / this.zoom) - 1; |
| this.zoom = newZoom; |
| |
| const { x: gx, y: gy } = this.viewport.getBoundingClientRect(); |
| const mouseOffsetX = (e.clientX - gx) - this.translation.x; |
| const mouseOffsetY = (e.clientY - gy) - this.translation.y; |
| this.translation.x -= mouseOffsetX * zoomDelta; |
| this.translation.y -= mouseOffsetY * zoomDelta; |
| } else { |
| this.translation.x -= e.deltaX; |
| this.translation.y -= e.deltaY; |
| } |
| |
| const clampedT = this.clampTranslation(this.translation, newZoom); |
| this.translation.x = clampedT.x; |
| this.translation.y = clampedT.y; |
| |
| this.animating = false; |
| this.updatePanAndZoom(); |
| }); |
| this.viewport.addEventListener("pointerdown", e => { |
| if (e.pointerType === "mouse" && !(e.button === 0 || e.button === 1)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this.viewport.setPointerCapture(e.pointerId); |
| this.startMousePos = { |
| x: e.clientX, |
| y: e.clientY, |
| }; |
| this.lastMousePos = { |
| x: e.clientX, |
| y: e.clientY, |
| }; |
| this.animating = false; |
| }); |
| this.viewport.addEventListener("pointermove", e => { |
| if (!this.viewport.hasPointerCapture(e.pointerId)) { |
| return; |
| } |
| |
| const dx = (e.clientX - this.lastMousePos.x); |
| const dy = (e.clientY - this.lastMousePos.y); |
| this.translation.x += dx; |
| this.translation.y += dy; |
| this.lastMousePos = { |
| x: e.clientX, |
| y: e.clientY, |
| }; |
| |
| const clampedT = this.clampTranslation(this.translation, this.zoom); |
| this.translation.x = clampedT.x; |
| this.translation.y = clampedT.y; |
| |
| this.animating = false; |
| this.updatePanAndZoom(); |
| }); |
| this.viewport.addEventListener("pointerup", e => { |
| this.viewport.releasePointerCapture(e.pointerId); |
| |
| const THRESHOLD = 2; |
| const deltaX = this.startMousePos.x - e.clientX; |
| const deltaY = this.startMousePos.y - e.clientY; |
| if (Math.abs(deltaX) <= THRESHOLD && Math.abs(deltaY) <= THRESHOLD) { |
| this.setSelection([]); |
| } |
| |
| this.animating = false; |
| }); |
| |
| // Observe resizing of the viewport (so we don't have to trigger style |
| // calculation in hot paths) |
| const ro = new ResizeObserver(entries => { |
| assert(entries.length === 1); |
| const rect = entries[0].contentRect; |
| this.viewportSize.x = rect.width; |
| this.viewportSize.y = rect.height; |
| }); |
| ro.observe(this.viewport); |
| } |
| |
| setSelection(blockPtrs: BlockPtr[], lastSelectedPtr: BlockPtr = 0 as BlockPtr) { |
| this.setSelectionRaw(blockPtrs, lastSelectedPtr); |
| if (!lastSelectedPtr) { |
| this.nav = { |
| visited: [], |
| currentIndex: -1, |
| siblings: [], |
| }; |
| } else { |
| this.nav = { |
| visited: [lastSelectedPtr], |
| currentIndex: 0, |
| siblings: [lastSelectedPtr], |
| }; |
| } |
| } |
| |
| private setSelectionRaw(blockPtrs: BlockPtr[], lastSelectedPtr: BlockPtr) { |
| this.selectedBlockPtrs.clear(); |
| for (const blockPtr of [...blockPtrs, lastSelectedPtr]) { |
| if (this.blocksByPtr.has(blockPtr)) { |
| this.selectedBlockPtrs.add(blockPtr); |
| } |
| } |
| this.lastSelectedBlockPtr = this.blocksByPtr.has(lastSelectedPtr) ? lastSelectedPtr : 0 as BlockPtr; |
| this.renderSelection(); |
| } |
| |
| navigate(dir: "down" | "up" | "left" | "right") { |
| const selected = this.lastSelectedBlockPtr; |
| |
| if (dir === "down" || dir === "up") { |
| // Vertical navigation |
| if (!selected) { |
| const blocks = [...this.blocks].sort((a, b) => a.id - b.id); |
| // No block selected; start navigation anew |
| const rootBlocks = blocks.filter(b => b.preds.length === 0); |
| const leafBlocks = blocks.filter(b => b.succs.length === 0); |
| const fauxSiblings = dir === "down" ? rootBlocks : leafBlocks; |
| const firstBlock = fauxSiblings[0]; |
| assert(firstBlock); |
| this.setSelectionRaw([], firstBlock.ptr); |
| this.nav = { |
| visited: [firstBlock.ptr], |
| currentIndex: 0, |
| siblings: fauxSiblings.map(b => b.ptr), |
| }; |
| } else { |
| // Move to the current block's successors or predecessors, |
| // respecting the visited stack |
| const currentBlock = must(this.blocksByPtr.get(selected)); |
| const nextSiblings: BlockPtr[] = ( |
| dir === "down" |
| ? currentBlock.succs |
| : currentBlock.preds |
| ).map(next => next.ptr); |
| |
| // If we have navigated to a different sibling at our current point in |
| // the stack, we have gone off our prior track and start a new one. |
| if (currentBlock.ptr !== this.nav.visited[this.nav.currentIndex]) { |
| this.nav.visited = [currentBlock.ptr]; |
| this.nav.currentIndex = 0; |
| } |
| |
| const nextIndex = this.nav.currentIndex + (dir === "down" ? 1 : -1); |
| if (0 <= nextIndex && nextIndex < this.nav.visited.length) { |
| // Move to existing block in visited stack |
| this.nav.currentIndex = nextIndex; |
| this.nav.siblings = nextSiblings; |
| } else { |
| // Push a new block onto the visited stack (either at the front or back) |
| const next: BlockPtr | undefined = nextSiblings[0]; |
| if (next !== undefined) { |
| if (dir === "down") { |
| this.nav.visited.push(next); |
| this.nav.currentIndex += 1; |
| assert(this.nav.currentIndex === this.nav.visited.length - 1); |
| } else { |
| this.nav.visited.unshift(next); |
| assert(this.nav.currentIndex === 0); |
| } |
| this.nav.siblings = nextSiblings; |
| } |
| } |
| |
| this.setSelectionRaw([], this.nav.visited[this.nav.currentIndex]); |
| } |
| } else { |
| // Horizontal navigation |
| if (selected !== undefined) { |
| const i = this.nav.siblings.indexOf(selected); |
| assert(i >= 0, "currently selected node should be in siblings array"); |
| const nextI = i + (dir === "right" ? 1 : -1); |
| if (0 <= nextI && nextI < this.nav.siblings.length) { |
| this.setSelectionRaw([], this.nav.siblings[nextI]); |
| } |
| } |
| } |
| |
| assert(this.nav.visited.length === 0 || this.nav.siblings.includes(this.nav.visited[this.nav.currentIndex]), "expected currently visited node to be in the siblings array"); |
| assert(this.lastSelectedBlockPtr === 0 || this.nav.siblings.includes(this.lastSelectedBlockPtr), "expected currently selected block to be in siblings array"); |
| } |
| |
| toggleInstructionHighlight(insPtr: InsPtr, force?: boolean) { |
| this.removeNonexistentHighlights(); |
| |
| const indexOfExisting = this.highlightedInstructions.findIndex(hi => hi.ptr === insPtr); |
| let remove = indexOfExisting >= 0; |
| if (force !== undefined) { |
| remove = !force; |
| } |
| |
| if (remove) { |
| if (indexOfExisting >= 0) { |
| this.highlightedInstructions.splice(indexOfExisting, 1); |
| } |
| } else { |
| if (indexOfExisting < 0) { |
| let nextPaletteColor = 0; |
| while (true) { |
| if (this.highlightedInstructions.find(hi => hi.paletteColor === nextPaletteColor)) { |
| nextPaletteColor += 1; |
| continue; |
| } |
| break; |
| } |
| |
| this.highlightedInstructions.push({ |
| ptr: insPtr, |
| paletteColor: nextPaletteColor, |
| }); |
| } |
| } |
| |
| this.updateHighlightedInstructions(); |
| } |
| |
| private clampTranslation(t: Vec2, scale: number): Vec2 { |
| const minX = TRANSLATION_CLAMP_AMOUNT - this.size.x * scale; |
| const maxX = this.viewportSize.x - TRANSLATION_CLAMP_AMOUNT; |
| const minY = TRANSLATION_CLAMP_AMOUNT - this.size.y * scale; |
| const maxY = this.viewportSize.y - TRANSLATION_CLAMP_AMOUNT; |
| |
| const newX = clamp(t.x, minX, maxX); |
| const newY = clamp(t.y, minY, maxY); |
| |
| return { x: newX, y: newY }; |
| } |
| |
| updatePanAndZoom() { |
| // We clamp here as well as in the input events because we want to respect |
| // the clamped limits even when jumping from pass to pass. But then when we |
| // actually receive input we want the clamping to "stick". |
| const clampedT = this.clampTranslation(this.translation, this.zoom); |
| this.graphContainer.style.transform = `translate(${clampedT.x}px, ${clampedT.y}px) scale(${this.zoom})`; |
| } |
| |
| /** |
| * Converts from graph space to viewport space. |
| */ |
| graph2viewport(v: Vec2, translation: Vec2 = this.translation, zoom: number = this.zoom): Vec2 { |
| return { |
| x: v.x * zoom + translation.x, |
| y: v.y * zoom + translation.y, |
| }; |
| } |
| |
| /** |
| * Converts from viewport space to graph space. |
| */ |
| viewport2graph(v: Vec2, translation: Vec2 = this.translation, zoom: number = this.zoom): Vec2 { |
| return { |
| x: (v.x - translation.x) / zoom, |
| y: (v.y - translation.y) / zoom, |
| }; |
| } |
| |
| /** |
| * Pans and zooms the graph such that the given x and y in graph space are in |
| * the top left of the viewport. |
| */ |
| async goToGraphCoordinates( |
| coords: Vec2, |
| { zoom = this.zoom, animate = true }: { |
| zoom?: number, |
| animate?: boolean |
| }, |
| ) { |
| const newTranslation = { x: -coords.x * zoom, y: -coords.y * zoom }; |
| |
| if (!animate) { |
| this.animating = false; |
| this.translation.x = newTranslation.x; |
| this.translation.y = newTranslation.y; |
| this.zoom = zoom; |
| this.updatePanAndZoom(); |
| await new Promise(res => setTimeout(res, 0)); |
| return; |
| } |
| |
| this.targetTranslation = newTranslation; |
| this.targetZoom = zoom; |
| if (this.animating) { |
| // Do not start another animation loop. |
| // |
| // TODO: Be fancy and return a promise that will resolve when the |
| // existing animation loop resolves. |
| return; |
| } |
| |
| this.animating = true; |
| let lastTime = performance.now(); |
| while (this.animating) { |
| const now = await new Promise<number>(res => requestAnimationFrame(res)); |
| const dt = (now - lastTime) / 1000; |
| lastTime = now; |
| |
| const THRESHOLD_T = 1, THRESHOLD_ZOOM = 0.01; |
| const R = 0.000001; // fraction remaining after one second: smaller = faster |
| const dx = this.targetTranslation.x - this.translation.x; |
| const dy = this.targetTranslation.y - this.translation.y; |
| const dzoom = this.targetZoom - this.zoom; |
| this.translation.x = filerp(this.translation.x, this.targetTranslation.x, R, dt); |
| this.translation.y = filerp(this.translation.y, this.targetTranslation.y, R, dt); |
| this.zoom = filerp(this.zoom, this.targetZoom, R, dt); |
| this.updatePanAndZoom(); |
| |
| if ( |
| Math.abs(dx) <= THRESHOLD_T |
| && Math.abs(dy) <= THRESHOLD_T |
| && Math.abs(dzoom) <= THRESHOLD_ZOOM |
| ) { |
| this.translation.x = this.targetTranslation.x; |
| this.translation.y = this.targetTranslation.y; |
| this.zoom = this.targetZoom; |
| this.animating = false; |
| this.updatePanAndZoom(); |
| break; |
| } |
| } |
| |
| // Delay by one update so that CSS changes before/after animation will |
| // always take effect, e.g. for .ig-flash. |
| await new Promise(res => setTimeout(res, 0)); |
| } |
| |
| jumpToBlock( |
| blockPtr: BlockPtr, |
| { zoom = this.zoom, animate = true, viewportPos }: { |
| zoom?: number, |
| animate?: boolean, |
| viewportPos?: Vec2, |
| } = {}, |
| ) { |
| const block = this.blocksByPtr.get(blockPtr); |
| if (!block) { |
| return Promise.resolve(); |
| } |
| |
| // If layout hasn't been computed yet, defer the jump until after layout |
| if (!block.layoutNode) { |
| this.deferredJumpToBlock = { blockPtr, opts: { zoom, animate, viewportPos } }; |
| return Promise.resolve(); |
| } |
| |
| let graphCoords: Vec2; |
| if (viewportPos) { |
| graphCoords = { |
| x: block.layoutNode.pos.x - viewportPos.x / zoom, |
| y: block.layoutNode.pos.y - viewportPos.y / zoom, |
| }; |
| } else { |
| graphCoords = this.graphPosToCenterRect(block.layoutNode.pos, block.layoutNode.size, zoom); |
| } |
| return this.goToGraphCoordinates(graphCoords, { zoom, animate }); |
| } |
| |
| async jumpToInstruction( |
| insID: InsID, |
| { zoom = this.zoom, animate = true }: { |
| zoom?: number, |
| animate?: boolean, |
| }, |
| ) { |
| // Since we don't have graph-layout coordinates for instructions, we have |
| // to reverse engineer them from their client position. |
| const insEl = this.graphContainer.querySelector<HTMLElement>(`.ig-ins[data-ig-ins-id="${insID}"]`); |
| if (!insEl) { |
| return; |
| } |
| |
| const insRect = insEl.getBoundingClientRect(); |
| const graphRect = this.graphContainer.getBoundingClientRect(); |
| |
| const x = (insRect.x - graphRect.x) / this.zoom; |
| const y = (insRect.y - graphRect.y) / this.zoom; |
| const width = insRect.width / this.zoom; |
| const height = insRect.height / this.zoom; |
| |
| const coords = this.graphPosToCenterRect({ x, y }, { x: width, y: height }, zoom); |
| insEl.classList.add("ig-flash"); |
| await this.goToGraphCoordinates(coords, { zoom, animate }); |
| insEl.classList.remove("ig-flash"); |
| } |
| |
| /** |
| * Returns the position in graph space that, if panned to, will center the |
| * given graph-space rectangle in the viewport. |
| */ |
| graphPosToCenterRect(pos: Vec2, size: Vec2, zoom: number): Vec2 { |
| const viewportWidth = this.viewportSize.x / zoom; |
| const viewportHeight = this.viewportSize.y / zoom; |
| const xPadding = Math.max(20 / zoom, (viewportWidth - size.x) / 2); |
| const yPadding = Math.max(20 / zoom, (viewportHeight - size.y) / 2); |
| const x = pos.x - xPadding; |
| const y = pos.y - yPadding; |
| return { x, y }; |
| } |
| |
| exportState(): GraphState { |
| const state: GraphState = { |
| translation: this.translation, |
| zoom: this.zoom, |
| heatmapMode: this.heatmapMode, |
| highlightedInstructions: this.highlightedInstructions, |
| selectedBlockPtrs: this.selectedBlockPtrs, |
| lastSelectedBlockPtr: this.lastSelectedBlockPtr, |
| |
| viewportPosOfSelectedBlock: undefined, |
| }; |
| |
| if (this.lastSelectedBlockPtr) { |
| state.viewportPosOfSelectedBlock = this.graph2viewport(must(this.blocksByPtr.get(this.lastSelectedBlockPtr)).layoutNode.pos); |
| } |
| |
| return state; |
| } |
| |
| restoreState(state: GraphState, opts: RestoreStateOpts) { |
| this.translation.x = state.translation.x; |
| this.translation.y = state.translation.y; |
| this.zoom = state.zoom; |
| this.heatmapMode = state.heatmapMode; |
| this.highlightedInstructions = state.highlightedInstructions; |
| this.setSelection(Array.from(state.selectedBlockPtrs), state.lastSelectedBlockPtr); |
| |
| this.updatePanAndZoom(); |
| this.updateHotness(); |
| this.updateHighlightedInstructions(); |
| |
| // If there was no last selected block, or if the last selected block no |
| // longer exists, jumpToBlock will do nothing. This is fine. |
| if (opts.preserveSelectedBlockPosition) { |
| this.jumpToBlock(this.lastSelectedBlockPtr, { |
| zoom: this.zoom, |
| animate: false, |
| viewportPos: state.viewportPosOfSelectedBlock, |
| }); |
| } |
| } |
| } |
| |
| function* dummies(layoutNodesByLayer: LayoutNode[][]) { |
| for (const nodes of layoutNodesByLayer) { |
| for (const node of nodes) { |
| if (node.block === null) { |
| yield node; |
| } |
| } |
| } |
| } |
| |
| function downwardArrow( |
| x1: number, y1: number, |
| x2: number, y2: number, |
| ym: number, |
| doArrowhead: boolean, |
| stroke = 1, |
| ): SVGElement { |
| const r = ARROW_RADIUS; |
| |
| // Align stroke to pixels |
| if (stroke % 2 === 1) { |
| x1 += 0.5; |
| x2 += 0.5; |
| ym += 0.5; |
| } |
| |
| let path = ""; |
| path += `M ${x1} ${y1} `; // move to start |
| |
| if (Math.abs(x2 - x1) < 2 * r) { |
| // Degenerate case where the radii won't fit; fall back to bezier. |
| path += `C ${x1} ${y1 + (y2 - y1) / 3} ${x2} ${y1 + 2 * (y2 - y1) / 3} ${x2} ${y2} `; |
| } else { |
| const dir = Math.sign(x2 - x1); |
| path += `L ${x1} ${ym - r} `; // line down |
| path += `A ${r} ${r} 0 0 ${dir > 0 ? 0 : 1} ${x1 + r * dir} ${ym} `; // arc to joint |
| path += `L ${x2 - r * dir} ${ym} `; // joint |
| path += `A ${r} ${r} 0 0 ${dir > 0 ? 1 : 0} ${x2} ${ym + r} `; // arc to line |
| path += `L ${x2} ${y2} `; // line down |
| } |
| |
| const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); |
| const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| p.setAttribute("d", path); |
| p.setAttribute("fill", "none"); |
| p.setAttribute("stroke", "black"); |
| p.setAttribute("stroke-width", `${stroke} `); |
| g.appendChild(p); |
| |
| if (doArrowhead) { |
| const v = arrowhead(x2, y2, 180); |
| g.appendChild(v); |
| } |
| |
| return g; |
| } |
| |
| function upwardArrow( |
| x1: number, y1: number, |
| x2: number, y2: number, |
| ym: number, |
| doArrowhead: boolean, |
| stroke = 1, |
| ): SVGElement { |
| const r = ARROW_RADIUS; |
| |
| // Align stroke to pixels |
| if (stroke % 2 === 1) { |
| x1 += 0.5; |
| x2 += 0.5; |
| ym += 0.5; |
| } |
| |
| let path = ""; |
| path += `M ${x1} ${y1} `; // move to start |
| |
| if (Math.abs(x2 - x1) < 2 * r) { |
| // Degenerate case where the radii won't fit; fall back to bezier. |
| path += `C ${x1} ${y1 + (y2 - y1) / 3} ${x2} ${y1 + 2 * (y2 - y1) / 3} ${x2} ${y2} `; |
| } else { |
| const dir = Math.sign(x2 - x1); |
| path += `L ${x1} ${ym + r} `; // line up |
| path += `A ${r} ${r} 0 0 ${dir > 0 ? 1 : 0} ${x1 + r * dir} ${ym} `; // arc to joint |
| path += `L ${x2 - r * dir} ${ym} `; // joint |
| path += `A ${r} ${r} 0 0 ${dir > 0 ? 0 : 1} ${x2} ${ym - r} `; // arc to line |
| path += `L ${x2} ${y2} `; // line up |
| } |
| |
| const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); |
| const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| p.setAttribute("d", path); |
| p.setAttribute("fill", "none"); |
| p.setAttribute("stroke", "black"); |
| p.setAttribute("stroke-width", `${stroke} `); |
| g.appendChild(p); |
| |
| if (doArrowhead) { |
| const v = arrowhead(x2, y2, 0); |
| g.appendChild(v); |
| } |
| |
| return g; |
| } |
| |
| function arrowFromBlockToBackedgeDummy( |
| x1: number, y1: number, |
| x2: number, y2: number, |
| ym: number, |
| stroke = 1, |
| ): SVGElement { |
| const r = ARROW_RADIUS; |
| |
| if (stroke % 2 === 1) { |
| x1 += 0.5; |
| x2 += 0.5; |
| ym += 0.5; |
| } |
| |
| let path = ""; |
| path += `M ${x1} ${y1} `; // move to start |
| path += `L ${x1} ${ym - r} `; // line down |
| path += `A ${r} ${r} 0 0 0 ${x1 + r} ${ym} `; // arc to horizontal joint |
| path += `L ${x2 - r} ${ym} `; // horizontal joint |
| path += `A ${r} ${r} 0 0 0 ${x2} ${ym - r} `; // arc to line |
| path += `L ${x2} ${y2} `; // line up (or down depending on y2) |
| |
| const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); |
| |
| const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| p.setAttribute("d", path); |
| p.setAttribute("fill", "none"); |
| p.setAttribute("stroke", "black"); |
| p.setAttribute("stroke-width", `${stroke} `); |
| g.appendChild(p); |
| |
| return g; |
| } |
| |
| function arrowBackedgeEnd( |
| x1: number, y1: number, |
| x2: number, y2: number, |
| yHigh: number, |
| stroke = 1, |
| ): SVGElement { |
| // Draws the final segment of a backedge: |
| // Starts at x1,y1 (Dummy). Goes UP to yHigh. |
| // Then LEFT to x2. |
| // Then DOWN to y2 (Block). |
| const r = ARROW_RADIUS; |
| |
| // Align stroke to pixels |
| if (stroke % 2 === 1) { |
| x1 += 0.5; |
| x2 += 0.5; |
| y1 += 0.5; |
| y2 += 0.5; |
| yHigh += 0.5; |
| } |
| |
| let path = ""; |
| path += `M ${x1} ${y1} `; // move to start |
| path += `L ${x1} ${yHigh + r} `; // Up |
| path += `A ${r} ${r} 0 0 0 ${x1 - r} ${yHigh} `; // Arc left |
| path += `L ${x2 + r} ${yHigh} `; // Left |
| path += `A ${r} ${r} 0 0 0 ${x2} ${yHigh + r} `; // Arc down |
| path += `L ${x2} ${y2} `; // Down |
| |
| const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); |
| const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| p.setAttribute("d", path); |
| p.setAttribute("fill", "none"); |
| p.setAttribute("stroke", "black"); |
| p.setAttribute("stroke-width", `${stroke} `); |
| g.appendChild(p); |
| |
| const v = arrowhead(x2, y2, 180); // Pointing down |
| g.appendChild(v); |
| |
| return g; |
| } |
| |
| function arrowhead(x: number, y: number, rot: number, size = 5): SVGElement { |
| const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| p.setAttribute("d", `M 0 0 L ${-size} ${size * 1.5} L ${size} ${size * 1.5} Z`); |
| p.setAttribute("transform", `translate(${x}, ${y}) rotate(${rot})`); |
| return p; |
| } |
| |
| function highlight(el: HTMLElement, color: string) { |
| el.classList.add("ig-highlight"); |
| el.style.setProperty("--ig-highlight-color", color); |
| } |
| |
| function clearHighlight(el: HTMLElement) { |
| el.classList.remove("ig-highlight"); |
| el.style.setProperty("--ig-highlight-color", "transparent"); |
| } |