From 7630ab90505cea8e98f971dee044375bdc26d358 Mon Sep 17 00:00:00 2001 From: mynameisdeleted Date: Thu, 9 Jul 2026 12:36:43 -0400 Subject: [PATCH] feat(debug): enhance node table visualizer with force-directed layout and toolbar controls --- vis-plugins/node-table-visualizer.js | 553 ++++++++++++++++++++------- 1 file changed, 425 insertions(+), 128 deletions(-) diff --git a/vis-plugins/node-table-visualizer.js b/vis-plugins/node-table-visualizer.js index f823a3b..b4afc7f 100644 --- a/vis-plugins/node-table-visualizer.js +++ b/vis-plugins/node-table-visualizer.js @@ -3,18 +3,20 @@ // as a row of boxes -- one per struct/node -- where each box is a table of // that node's member variables (address in the header, fields below, // pointer values in hex), connected by arrows for pointer fields. +// Nodes are draggable, the canvas is pannable/zoomable, and a toolbar lets +// you switch between a force-directed ("Auto", vis-network-style) layout +// and the original left-to-right layered chain layout, or fit everything +// back into view. // // Registered via the "debugVisualizer.customVisualizerScriptPaths" setting; // the extension hot-reloads this file on save, no debug session restart needed. // -// Data shape produced by cpp/main.cpp's LinkedList::toDebugGraph(): +// Data shape produced by gdb/debug_graph.py's $debug_graph(...): // nodes: [{ id: "0x...", fields: [{ name, value, isPointer? }, ...] }] // edges: [{ from: "0x...", to: "0x...", label? }] // roots: [{ name: "top", value: "0x..." }] -- external pointer variables // (e.g. a local `top`/`end` in the caller's stack frame); drawn as a -// small pill above the graph with an arrow to whichever node's id -// matches its value, since a node's own address equals its first -// member's address. +// small pill that tracks above whichever node's id matches its value. module.exports = function (register, lib) { var sj = lib.semanticJson; @@ -54,17 +56,16 @@ module.exports = function (register, lib) { var NODE_WIDTH = 220; var H_GAP = 70; var V_GAP = 24; - var PADDING = 20; - var ROOT_ROW_HEIGHT = 50; var ROOT_BADGE_HEIGHT = 22; + var ROOT_GAP = 30; + var MIN_ZOOM = 0.15; + var MAX_ZOOM = 3; - function layout(nodes, edges) { - var ids = nodes.map(function (n) { - return n.id; - }); + // ---- seed layout: left-to-right BFS levels, also used as "Layered" mode ---- + function layeredPositions(nodeIds, edges, boxHeights) { var adjacency = {}; var incoming = {}; - ids.forEach(function (id) { + nodeIds.forEach(function (id) { adjacency[id] = []; incoming[id] = 0; }); @@ -75,15 +76,15 @@ module.exports = function (register, lib) { var level = {}; var queue = []; - ids.forEach(function (id) { + nodeIds.forEach(function (id) { if (incoming[id] === 0) { level[id] = 0; queue.push(id); } }); - if (queue.length === 0 && ids.length > 0) { - level[ids[0]] = 0; - queue.push(ids[0]); + if (queue.length === 0 && nodeIds.length > 0) { + level[nodeIds[0]] = 0; + queue.push(nodeIds[0]); } var head = 0; @@ -102,7 +103,7 @@ module.exports = function (register, lib) { var colCount = {}; var positions = {}; - ids.forEach(function (id) { + nodeIds.forEach(function (id) { var lvl = level[id]; if (lvl === undefined) { maxLevel++; @@ -110,11 +111,90 @@ module.exports = function (register, lib) { } var col = colCount[lvl] || 0; colCount[lvl] = col + 1; - positions[id] = { level: lvl, col: col }; + var x = lvl * (NODE_WIDTH + H_GAP) + NODE_WIDTH / 2; + var y = col * (Math.max(boxHeights[id], HEADER_HEIGHT + ROW_HEIGHT) + V_GAP) + boxHeights[id] / 2; + positions[id] = { x: x, y: y }; }); return positions; } + // ---- simple O(n^2) force-directed layout, vis-network-style ---- + function createSimulation(nodeList, edges) { + var REPULSION = 55000; + var SPRING_LENGTH = NODE_WIDTH + H_GAP; + var SPRING_STIFFNESS = 0.02; + var DAMPING = 0.82; + var GRAVITY = 0.01; + + var byId = {}; + nodeList.forEach(function (n) { + byId[n.id] = n; + }); + + function step() { + var fx = {}; + var fy = {}; + nodeList.forEach(function (n) { + fx[n.id] = 0; + fy[n.id] = 0; + }); + + for (var i = 0; i < nodeList.length; i++) { + for (var j = i + 1; j < nodeList.length; j++) { + var a = nodeList[i]; + var b = nodeList[j]; + var dx = a.x - b.x; + var dy = a.y - b.y; + var distSq = dx * dx + dy * dy || 0.01; + var dist = Math.sqrt(distSq); + var force = REPULSION / distSq; + var ux = dx / dist; + var uy = dy / dist; + fx[a.id] += ux * force; + fy[a.id] += uy * force; + fx[b.id] -= ux * force; + fy[b.id] -= uy * force; + } + } + + edges.forEach(function (e) { + var a = byId[e.from]; + var b = byId[e.to]; + if (!a || !b) return; + var dx = b.x - a.x; + var dy = b.y - a.y; + var dist = Math.sqrt(dx * dx + dy * dy) || 0.01; + var diff = dist - SPRING_LENGTH; + var force = diff * SPRING_STIFFNESS; + var ux = dx / dist; + var uy = dy / dist; + fx[a.id] += ux * force; + fy[a.id] += uy * force; + fx[b.id] -= ux * force; + fy[b.id] -= uy * force; + }); + + var kinetic = 0; + nodeList.forEach(function (n) { + if (n.fixed) { + n.vx = 0; + n.vy = 0; + return; + } + fx[n.id] -= n.x * GRAVITY; + fy[n.id] -= n.y * GRAVITY; + n.vx = (n.vx + fx[n.id]) * DAMPING; + n.vy = (n.vy + fy[n.id]) * DAMPING; + n.x += n.vx; + n.y += n.vy; + kinetic += n.vx * n.vx + n.vy * n.vy; + }); + return kinetic; + } + + return { nodes: nodeList, byId: byId, step: step }; + } + function el(tag, style, attrs) { var e = document.createElement(tag); if (style) Object.assign(e.style, style); @@ -124,15 +204,32 @@ module.exports = function (register, lib) { return e; } + function button(label, colors) { + var b = el( + "button", + { + font: "12px var(--vscode-editor-font-family, monospace)", + padding: "3px 8px", + borderRadius: "4px", + border: "1px solid " + colors.border, + background: colors.headerBg, + color: colors.headerFg, + cursor: "pointer", + }, + { type: "button" } + ); + b.textContent = label; + return b; + } + function render(data, target, theme) { target.innerHTML = ""; Object.assign(target.style, { position: "relative", - overflow: "auto", + overflow: "hidden", width: "100%", height: "100%", boxSizing: "border-box", - padding: PADDING + "px", }); var dark = theme === "dark"; @@ -146,6 +243,7 @@ module.exports = function (register, lib) { fieldValue: "#d4d4d4", pointerValue: "#ce9178", edge: "#8a8a8a", + viewportBg: "#1e1e1e", } : { border: "#b8b8b8", @@ -156,16 +254,10 @@ module.exports = function (register, lib) { fieldValue: "#1a1a1a", pointerValue: "#a31515", edge: "#6b6b6b", + viewportBg: "#fafafa", }; - var positions = layout(data.nodes, data.edges); var roots = data.roots || []; - var topOffset = roots.length > 0 ? ROOT_ROW_HEIGHT : 0; - - var byId = {}; - data.nodes.forEach(function (n) { - byId[n.id] = n; - }); var boxHeights = {}; data.nodes.forEach(function (n) { @@ -173,57 +265,46 @@ module.exports = function (register, lib) { boxHeights[n.id] = HEADER_HEIGHT + Math.max(visibleRows, 1) * ROW_HEIGHT; }); - var pixelPos = {}; - var maxX = 0; - var maxY = 0; - Object.keys(positions).forEach(function (id) { - var p = positions[id]; - var x = p.level * (NODE_WIDTH + H_GAP); - var y = topOffset + p.col * (Math.max(boxHeights[id], HEADER_HEIGHT + ROW_HEIGHT) + V_GAP); - pixelPos[id] = { x: x, y: y }; - maxX = Math.max(maxX, x + NODE_WIDTH); - maxY = Math.max(maxY, y + boxHeights[id]); + var nodeIds = data.nodes.map(function (n) { + return n.id; + }); + var seed = layeredPositions(nodeIds, data.edges, boxHeights); + var nodeList = data.nodes.map(function (n) { + var p = seed[n.id]; + return { id: n.id, x: p.x, y: p.y, vx: 0, vy: 0, fixed: false, height: boxHeights[n.id] }; + }); + var byId = {}; + nodeList.forEach(function (n) { + byId[n.id] = n; }); - // Spread root badges above the nodes they point to; unmatched roots - // (e.g. a pointer into the middle of a struct, or a stale/null value) - // queue up along the top-left instead. - var rootPos = {}; - var nextUnmatchedX = 0; - var badgesPerTarget = {}; - roots.forEach(function (r) { - var target = pixelPos[r.value]; - var x; - if (target) { - var stackIndex = badgesPerTarget[r.value] || 0; - badgesPerTarget[r.value] = stackIndex + 1; - x = target.x + stackIndex * 90; - } else { - x = nextUnmatchedX; - nextUnmatchedX += NODE_WIDTH / 2 + 20; - } - rootPos[r.name] = { x: x, y: 0, matched: !!target }; - maxX = Math.max(maxX, x + NODE_WIDTH); + var sim = createSimulation(nodeList, data.edges); + var layoutMode = "auto"; // "auto" (physics) | "layered" (static chain) + var running = true; + + // ---- DOM: viewport (pan/zoom window) + world (transformed content) ---- + var viewport = el("div", { + position: "absolute", + left: "0", + top: "0", + width: "100%", + height: "100%", + overflow: "hidden", + background: colors.viewportBg, + cursor: "grab", }); - var canvas = el("div", { - position: "relative", - width: maxX + "px", - height: maxY + "px", + var world = el("div", { + position: "absolute", + left: "0", + top: "0", + transformOrigin: "0 0", }); var svgNs = "http://www.w3.org/2000/svg"; var svg = document.createElementNS(svgNs, "svg"); - svg.setAttribute("width", String(maxX)); - svg.setAttribute("height", String(maxY)); - Object.assign(svg.style, { - position: "absolute", - left: "0", - top: "0", - overflow: "visible", - pointerEvents: "none", - }); - + Object.assign(svg.style, { position: "absolute", left: "0", top: "0", overflow: "visible", pointerEvents: "none" }); + var defs = document.createElementNS(svgNs, "defs"); var marker = document.createElementNS(svgNs, "marker"); marker.setAttribute("id", "dv-arrow"); marker.setAttribute("markerWidth", "8"); @@ -235,35 +316,20 @@ module.exports = function (register, lib) { arrowPath.setAttribute("d", "M0,0 L8,4 L0,8 Z"); arrowPath.setAttribute("fill", colors.edge); marker.appendChild(arrowPath); - var defs = document.createElementNS(svgNs, "defs"); defs.appendChild(marker); svg.appendChild(defs); + world.appendChild(svg); - data.edges.forEach(function (e) { - var from = pixelPos[e.from]; - var to = pixelPos[e.to]; - if (!from || !to) return; - var x1 = from.x + NODE_WIDTH; - var y1 = from.y + boxHeights[e.from] / 2; - var x2 = to.x; - var y2 = to.y + boxHeights[e.to] / 2; - + var edgePaths = data.edges.map(function (e) { var line = document.createElementNS(svgNs, "path"); - var midX = (x1 + x2) / 2; - line.setAttribute( - "d", - "M" + x1 + "," + y1 + " C " + midX + "," + y1 + " " + midX + "," + y2 + " " + x2 + "," + y2 - ); line.setAttribute("fill", "none"); line.setAttribute("stroke", colors.edge); line.setAttribute("stroke-width", "1.5"); line.setAttribute("marker-end", "url(#dv-arrow)"); svg.appendChild(line); - + var text = null; if (e.label) { - var text = document.createElementNS(svgNs, "text"); - text.setAttribute("x", String(midX)); - text.setAttribute("y", String((y1 + y2) / 2 - 4)); + text = document.createElementNS(svgNs, "text"); text.setAttribute("fill", colors.edge); text.setAttribute("font-size", "11"); text.setAttribute("font-family", "monospace"); @@ -271,16 +337,13 @@ module.exports = function (register, lib) { text.textContent = e.label; svg.appendChild(text); } + return { edge: e, line: line, text: text }; }); - canvas.appendChild(svg); - + var boxes = {}; data.nodes.forEach(function (n) { - var pos = pixelPos[n.id]; var box = el("div", { position: "absolute", - left: pos.x + "px", - top: pos.y + "px", width: NODE_WIDTH + "px", border: "1px solid " + colors.border, borderRadius: "6px", @@ -289,6 +352,7 @@ module.exports = function (register, lib) { fontFamily: "var(--vscode-editor-font-family, monospace)", fontSize: "12px", overflow: "hidden", + cursor: "grab", }); var header = el("div", { @@ -304,15 +368,8 @@ module.exports = function (register, lib) { header.textContent = n.id; box.appendChild(header); - var body = el("div", { - maxHeight: MAX_VISIBLE_ROWS * ROW_HEIGHT + "px", - overflowY: "auto", - }); - - var table = el("table", { - borderCollapse: "collapse", - width: "100%", - }); + var body = el("div", { maxHeight: MAX_VISIBLE_ROWS * ROW_HEIGHT + "px", overflowY: "auto" }); + var table = el("table", { borderCollapse: "collapse", width: "100%" }); n.fields.forEach(function (f) { var row = el("tr"); @@ -340,39 +397,22 @@ module.exports = function (register, lib) { body.appendChild(table); box.appendChild(body); - canvas.appendChild(box); + world.appendChild(box); + boxes[n.id] = box; }); - roots.forEach(function (r) { - var pos = rootPos[r.name]; - if (pos.matched) { - var targetPos = pixelPos[r.value]; - var x1 = pos.x + NODE_WIDTH / 2; - var y1 = ROOT_BADGE_HEIGHT; - var x2 = targetPos.x + NODE_WIDTH / 2; - var y2 = targetPos.y; - var line = document.createElementNS(svgNs, "path"); - line.setAttribute("d", "M" + x1 + "," + y1 + " L " + x2 + "," + y2); - line.setAttribute("fill", "none"); - line.setAttribute("stroke", colors.edge); - line.setAttribute("stroke-width", "1.5"); - line.setAttribute("stroke-dasharray", "3,3"); - line.setAttribute("marker-end", "url(#dv-arrow)"); - svg.appendChild(line); - } - + var rootBadges = roots.map(function (r) { + var matched = !!byId[r.value]; var badge = el("div", { position: "absolute", - left: pos.x + "px", - top: "0px", width: NODE_WIDTH / 2 + "px", height: ROOT_BADGE_HEIGHT + "px", lineHeight: ROOT_BADGE_HEIGHT + "px", textAlign: "center", borderRadius: "11px", border: "1px solid " + colors.border, - background: pos.matched ? colors.headerBg : "transparent", - color: pos.matched ? colors.headerFg : colors.pointerValue, + background: matched ? colors.headerBg : "transparent", + color: matched ? colors.headerFg : colors.pointerValue, fontFamily: "var(--vscode-editor-font-family, monospace)", fontSize: "12px", fontWeight: "bold", @@ -381,11 +421,268 @@ module.exports = function (register, lib) { textOverflow: "ellipsis", }); badge.title = r.name + " = " + r.value; - badge.textContent = r.name + (pos.matched ? "" : " (" + r.value + ")"); - canvas.appendChild(badge); + badge.textContent = r.name + (matched ? "" : " (" + r.value + ")"); + world.appendChild(badge); + var line = null; + if (matched) { + line = document.createElementNS(svgNs, "path"); + line.setAttribute("fill", "none"); + line.setAttribute("stroke", colors.edge); + line.setAttribute("stroke-width", "1.5"); + line.setAttribute("stroke-dasharray", "3,3"); + line.setAttribute("marker-end", "url(#dv-arrow)"); + svg.appendChild(line); + } + return { root: r, badge: badge, line: line, matched: matched, x: 0 }; + }); + var nextUnmatchedX = 0; + var badgesPerTarget = {}; + + viewport.appendChild(world); + target.appendChild(viewport); + + // ---- toolbar ---- + var toolbar = el("div", { + position: "absolute", + right: "8px", + top: "8px", + display: "flex", + gap: "6px", + zIndex: "10", + }); + var autoBtn = button("Auto-arrange", colors); + var layeredBtn = button("Layered", colors); + var fitBtn = button("Fit", colors); + var zoomOutBtn = button("−", colors); + var zoomInBtn = button("+", colors); + [autoBtn, layeredBtn, fitBtn, zoomOutBtn, zoomInBtn].forEach(function (b) { + toolbar.appendChild(b); + }); + target.appendChild(toolbar); + + function highlightModeButtons() { + autoBtn.style.background = layoutMode === "auto" ? colors.pointerValue : colors.headerBg; + layeredBtn.style.background = layoutMode === "layered" ? colors.pointerValue : colors.headerBg; + } + highlightModeButtons(); + + // ---- camera (pan/zoom) ---- + var camera = { x: target.clientWidth / 2 - NODE_WIDTH / 2, y: 40, zoom: 1 }; + + function applyCamera() { + world.style.transform = "translate(" + camera.x + "px, " + camera.y + "px) scale(" + camera.zoom + ")"; + } + + function zoomBy(factor, centerX, centerY) { + var newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, camera.zoom * factor)); + camera.x = centerX - ((centerX - camera.x) * newZoom) / camera.zoom; + camera.y = centerY - ((centerY - camera.y) * newZoom) / camera.zoom; + camera.zoom = newZoom; + applyCamera(); + } + + function fitToView() { + if (nodeList.length === 0) return; + var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + nodeList.forEach(function (n) { + minX = Math.min(minX, n.x - NODE_WIDTH / 2); + maxX = Math.max(maxX, n.x + NODE_WIDTH / 2); + minY = Math.min(minY, n.y - n.height / 2 - (roots.length ? ROOT_BADGE_HEIGHT + ROOT_GAP : 0)); + maxY = Math.max(maxY, n.y + n.height / 2); + }); + var pad = 30; + var w = Math.max(1, maxX - minX + pad * 2); + var h = Math.max(1, maxY - minY + pad * 2); + var vw = target.clientWidth || 800; + var vh = target.clientHeight || 600; + var zoom = Math.min(MAX_ZOOM, Math.min(vw / w, vh / h)); + camera.zoom = zoom; + camera.x = vw / 2 - ((minX + maxX) / 2) * zoom; + camera.y = vh / 2 - ((minY + maxY) / 2) * zoom; + applyCamera(); + } + + viewport.addEventListener( + "wheel", + function (e) { + e.preventDefault(); + var rect = viewport.getBoundingClientRect(); + var factor = Math.pow(1.0015, -e.deltaY); + zoomBy(factor, e.clientX - rect.left, e.clientY - rect.top); + }, + { passive: false } + ); + + zoomInBtn.addEventListener("click", function () { + zoomBy(1.2, target.clientWidth / 2, target.clientHeight / 2); + }); + zoomOutBtn.addEventListener("click", function () { + zoomBy(1 / 1.2, target.clientWidth / 2, target.clientHeight / 2); + }); + fitBtn.addEventListener("click", fitToView); + autoBtn.addEventListener("click", function () { + layoutMode = "auto"; + highlightModeButtons(); + nodeList.forEach(function (n) { + if (!n.userPinned) { + n.fixed = false; + n.vx += (Math.random() - 0.5) * 2; + n.vy += (Math.random() - 0.5) * 2; + } + }); + stabilize(500); + redraw(); + fitToView(); + }); + layeredBtn.addEventListener("click", function () { + layoutMode = "layered"; + highlightModeButtons(); + var pos = layeredPositions(nodeIds, data.edges, boxHeights); + nodeList.forEach(function (n) { + n.x = pos[n.id].x; + n.y = pos[n.id].y; + n.vx = 0; + n.vy = 0; + n.fixed = true; + }); + redraw(); + fitToView(); }); - target.appendChild(canvas); + // ---- pan (drag on background) ---- + var panDrag = null; + viewport.addEventListener("mousedown", function (e) { + if (e.target !== viewport && e.target !== world && e.target !== svg) return; + panDrag = { startX: e.clientX, startY: e.clientY, camX: camera.x, camY: camera.y }; + viewport.style.cursor = "grabbing"; + e.preventDefault(); + }); + + // ---- node drag ---- + var nodeDrag = null; + data.nodes.forEach(function (n) { + var box = boxes[n.id]; + box.addEventListener("mousedown", function (e) { + e.stopPropagation(); + var node = byId[n.id]; + node.fixed = true; + node.userPinned = true; + nodeDrag = { node: node, startX: e.clientX, startY: e.clientY, nodeX: node.x, nodeY: node.y }; + box.style.cursor = "grabbing"; + e.preventDefault(); + }); + }); + + window.addEventListener("mousemove", function (e) { + if (panDrag) { + camera.x = panDrag.camX + (e.clientX - panDrag.startX); + camera.y = panDrag.camY + (e.clientY - panDrag.startY); + applyCamera(); + } else if (nodeDrag) { + nodeDrag.node.x = nodeDrag.nodeX + (e.clientX - nodeDrag.startX) / camera.zoom; + nodeDrag.node.y = nodeDrag.nodeY + (e.clientY - nodeDrag.startY) / camera.zoom; + redraw(); + } + }); + window.addEventListener("mouseup", function () { + if (panDrag) { + panDrag = null; + viewport.style.cursor = "grab"; + } + if (nodeDrag) { + var box = boxes[nodeDrag.node.id]; + if (box) box.style.cursor = "grab"; + nodeDrag = null; + } + }); + + // ---- per-frame redraw ---- + function redraw() { + nodeList.forEach(function (n) { + var box = boxes[n.id]; + box.style.left = n.x - NODE_WIDTH / 2 + "px"; + box.style.top = n.y - n.height / 2 + "px"; + }); + + edgePaths.forEach(function (ep) { + var a = byId[ep.edge.from]; + var b = byId[ep.edge.to]; + if (!a || !b) return; + var x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y; + var midX = (x1 + x2) / 2; + var midY = (y1 + y2) / 2; + ep.line.setAttribute( + "d", + "M" + x1 + "," + y1 + " C " + midX + "," + y1 + " " + midX + "," + y2 + " " + x2 + "," + y2 + ); + if (ep.text) { + ep.text.setAttribute("x", String(midX)); + ep.text.setAttribute("y", String(midY - 4)); + } + }); + + nextUnmatchedX = 0; + badgesPerTarget = {}; + rootBadges.forEach(function (rb) { + if (rb.matched) { + var target = byId[rb.root.value]; + var stackIndex = badgesPerTarget[rb.root.value] || 0; + badgesPerTarget[rb.root.value] = stackIndex + 1; + rb.x = target.x + stackIndex * 90; + var by = target.y - target.height / 2 - ROOT_GAP; + rb.badge.style.left = rb.x - NODE_WIDTH / 4 + "px"; + rb.badge.style.top = by + "px"; + if (rb.line) { + rb.line.setAttribute( + "d", + "M" + rb.x + "," + (by + ROOT_BADGE_HEIGHT) + " L " + target.x + "," + (target.y - target.height / 2) + ); + } + } else { + rb.x = nextUnmatchedX; + nextUnmatchedX += NODE_WIDTH / 2 + 20; + rb.badge.style.left = rb.x + "px"; + rb.badge.style.top = "0px"; + } + }); + } + + // ---- animation loop ---- + var lastKinetic = Infinity; + + // Force-directed layouts need many iterations to go from the seed + // positions to something settled; fitting the camera to the seed + // shape (then letting physics keep running) means nodes drift well + // outside whatever we framed. So run the simulation synchronously + // up front -- like vis-network's stabilization pass -- and only + // fit/paint once it's essentially settled. + function stabilize(maxSteps) { + var k = Infinity; + for (var i = 0; i < maxSteps; i++) { + k = sim.step(); + if (k < 0.01) break; + } + lastKinetic = k; + running = k >= 0.01; + } + + function frame() { + if (!document.body.contains(target)) return; // this render() call was superseded + if (layoutMode === "auto" && running) { + var kinetic = sim.step(); + if (kinetic < 0.01 && lastKinetic < 0.01) { + running = false; + } + lastKinetic = kinetic; + } + redraw(); + requestAnimationFrame(frame); + } + + stabilize(500); + redraw(); + fitToView(); + requestAnimationFrame(frame); } register({