|
1 | | -<!-- Span Visualiser Code --> |
| 1 | +<!doctype html> |
| 2 | +<html lang="en"> |
| 3 | + <head> |
| 4 | + <meta charset="utf-8" /> |
| 5 | + <title>Span Visualiser</title> |
| 6 | + <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| 7 | + <script type="module"> |
| 8 | + import * as d3 from 'https://cdn.skypack.dev/d3@7'; |
| 9 | + import { bases } from 'https://cdn.skypack.dev/multiformats/basics'; |
| 10 | + |
| 11 | + //— multibase → timestamp utilities (same as your snippet) — |
| 12 | + const unixtsSize = 36, |
| 13 | + msecSize = 12, |
| 14 | + msecPrecision = 3, |
| 15 | + indentPx = 1000; |
| 16 | + const basesByPrefix = {}; |
| 17 | + for (const c of Object.values(bases)) basesByPrefix[c.prefix] = c; |
| 18 | + function bytes2bits(bytes) { |
| 19 | + return Array.from(bytes) |
| 20 | + .map((b) => b.toString(2).padStart(8, '0')) |
| 21 | + .join(''); |
| 22 | + } |
| 23 | + function fromMultibase(str) { |
| 24 | + const codec = basesByPrefix[str[0]]; |
| 25 | + if (!codec) return; |
| 26 | + try { |
| 27 | + return codec.decode(str); |
| 28 | + } catch { |
| 29 | + return; |
| 30 | + } |
| 31 | + } |
| 32 | + function extractTs(idStr) { |
| 33 | + const bytes = fromMultibase(idStr); |
| 34 | + if (!bytes) return 0; |
| 35 | + const tsBytes = bytes.subarray(0, (unixtsSize + msecSize) / 8); |
| 36 | + const bits = bytes2bits(tsBytes); |
| 37 | + const u = parseInt(bits.slice(0, unixtsSize), 2); |
| 38 | + const m = parseInt(bits.slice(unixtsSize, unixtsSize + msecSize), 2); |
| 39 | + return u * 1000 + (m / 2 ** msecSize) * 1000; |
| 40 | + } |
| 41 | + |
| 42 | + //— load JSONL either via fetch or via file input fallback — |
| 43 | + function loadLines() { |
| 44 | + return new Promise((resolve) => { |
| 45 | + const input = document.createElement('input'); |
| 46 | + input.type = 'file'; |
| 47 | + input.accept = '.jsonl'; |
| 48 | + input.style = 'position:fixed;top:10px;left:10px;z-index:9999'; |
| 49 | + input.onchange = () => { |
| 50 | + const reader = new FileReader(); |
| 51 | + reader.onload = () => { |
| 52 | + const lines = reader.result.trim().split('\n'); |
| 53 | + resolve(lines); |
| 54 | + input.remove(); // clean up |
| 55 | + }; |
| 56 | + reader.readAsText(input.files[0]); |
| 57 | + }; |
| 58 | + document.body.appendChild(input); |
| 59 | + }); |
| 60 | + } |
| 61 | + |
| 62 | + //— main — |
| 63 | + (async () => { |
| 64 | + const rawLines = await loadLines(); |
| 65 | + const events = rawLines.map((l) => JSON.parse(l)); |
| 66 | + |
| 67 | + // 1) group by spanId |
| 68 | + const evBySpan = new Map(); |
| 69 | + for (const e of events) { |
| 70 | + const sid = e.type === 'start' ? e.id : e.startId; |
| 71 | + if (!evBySpan.has(sid)) evBySpan.set(sid, []); |
| 72 | + evBySpan.get(sid).push(e); |
| 73 | + } |
| 74 | + |
| 75 | + // 2) for each span, sort events, pick start/end, record parent |
| 76 | + const spanInfo = {}; |
| 77 | + for (const [sid, evts] of evBySpan.entries()) { |
| 78 | + evts.sort( |
| 79 | + (a, b) => |
| 80 | + extractTs(a.id || a.startId) - extractTs(b.id || b.startId), |
| 81 | + ); |
| 82 | + const startEvt = evts.find((e) => e.type === 'start'); |
| 83 | + const stopEvt = evts.find( |
| 84 | + (e) => e.type === 'stop' || e.type === 'end', |
| 85 | + ); |
| 86 | + spanInfo[sid] = { |
| 87 | + id: sid, |
| 88 | + name: startEvt?.name || sid, |
| 89 | + ts0: startEvt ? extractTs(startEvt.id) : Infinity, |
| 90 | + ts1: stopEvt ? extractTs(stopEvt.id) : -Infinity, |
| 91 | + parent: startEvt?.parentSpanId || startEvt?.parentId || null, |
| 92 | + hasEnd: !!stopEvt, |
| 93 | + }; |
| 94 | + } |
| 95 | + |
| 96 | + // 3) fix open spans → draw to maxTime |
| 97 | + const allStarts = Object.values(spanInfo).map((d) => d.ts0); |
| 98 | + const allEnds = Object.values(spanInfo) |
| 99 | + .map((d) => d.ts1) |
| 100 | + .filter((d) => d > 0); |
| 101 | + const minT = Math.min(...allStarts); |
| 102 | + const maxT = Math.max(...allEnds); |
| 103 | + for (const s of Object.values(spanInfo)) { |
| 104 | + if (s.ts1 < 0) s.ts1 = maxT; |
| 105 | + } |
| 106 | + |
| 107 | + // 4) build tree & compute depths |
| 108 | + const children = {}; |
| 109 | + for (const sid of Object.keys(spanInfo)) children[sid] = []; |
| 110 | + for (const s of Object.values(spanInfo)) { |
| 111 | + if (s.parent && spanInfo[s.parent]) children[s.parent].push(s.id); |
| 112 | + } |
| 113 | + const depths = {}; |
| 114 | + function assignDepth(sid, d = 0) { |
| 115 | + if (depths[sid] != null && depths[sid] <= d) return; |
| 116 | + depths[sid] = d; |
| 117 | + for (const c of children[sid]) assignDepth(c, d + 1); |
| 118 | + } |
| 119 | + for (const sid of Object.keys(spanInfo)) { |
| 120 | + if (!spanInfo[sid].parent || !spanInfo[spanInfo[sid].parent]) { |
| 121 | + assignDepth(sid, 0); |
| 122 | + } |
| 123 | + } |
| 124 | + // any orphan not yet depth-assigned → root |
| 125 | + for (const sid of Object.keys(spanInfo)) { |
| 126 | + if (depths[sid] == null) assignDepth(sid, 0); |
| 127 | + } |
| 128 | + const maxD = Math.max(...Object.values(depths)); |
| 129 | + |
| 130 | + // 5) set up SVG & scales |
| 131 | + const svg = d3 |
| 132 | + .select('svg') |
| 133 | + .attr('width', window.innerWidth) |
| 134 | + .attr('height', window.innerHeight); |
| 135 | + |
| 136 | + const margin = { top: 20, right: 20, bottom: 20, left: 20 }; |
| 137 | + const W = +svg.attr('width') - margin.left - margin.right; |
| 138 | + const H = +svg.attr('height') - margin.top - margin.bottom; |
| 139 | + |
| 140 | + // 5) Build hierarchical tree structure |
| 141 | + const rootNodes = []; |
| 142 | + const nodeMap = new Map(Object.entries(spanInfo)); |
| 143 | + |
| 144 | + nodeMap.forEach((node) => { |
| 145 | + if (!node.parent || !nodeMap.has(node.parent)) { |
| 146 | + rootNodes.push(node); |
| 147 | + } else { |
| 148 | + const parent = nodeMap.get(node.parent); |
| 149 | + parent.children = parent.children || []; |
| 150 | + parent.children.push(node); |
| 151 | + } |
| 152 | + }); |
| 153 | + |
| 154 | + // Sort tree branches by start time |
| 155 | + function sortTree(node) { |
| 156 | + if (node.children) { |
| 157 | + node.children.sort((a, b) => a.ts0 - b.ts0); |
| 158 | + node.children.forEach(sortTree); |
| 159 | + } |
| 160 | + } |
| 161 | + rootNodes.forEach(sortTree); |
| 162 | + |
| 163 | + // 6) Calculate vertical positions using tree structure |
| 164 | + const nodePositions = new Map(); |
| 165 | + let yPos = margin.top; |
| 166 | + const verticalSpacing = 30; |
| 167 | + |
| 168 | + function layoutTree(node, depth = 0) { |
| 169 | + nodePositions.set(node.id, yPos); |
| 170 | + yPos += verticalSpacing; |
| 171 | + |
| 172 | + if (node.children) { |
| 173 | + node.children.forEach(child => layoutTree(child, depth + 1)); |
| 174 | + } |
| 175 | + } |
| 176 | + rootNodes.forEach(node => layoutTree(node)); |
| 177 | + |
| 178 | + // 7) Set up scales |
| 179 | + const x = d3.scaleLinear() |
| 180 | + .domain([minT, maxT]) |
| 181 | + .range([margin.left, W - margin.right]); |
| 182 | + |
| 183 | + // arrowhead |
| 184 | + svg |
| 185 | + .append('defs') |
| 186 | + .append('marker') |
| 187 | + .attr('id', 'arrowHead') |
| 188 | + .attr('viewBox', '-5 -5 10 10') |
| 189 | + .attr('refX', 0) |
| 190 | + .attr('refY', 0) |
| 191 | + .attr('markerWidth', 6) |
| 192 | + .attr('markerHeight', 6) |
| 193 | + .attr('orient', 'auto') |
| 194 | + .append('path') |
| 195 | + .attr('d', 'M-5,-5L5,0L-5,5') |
| 196 | + .attr('fill', '#339'); |
| 197 | + |
| 198 | + // pan/zoom |
| 199 | + const g = svg.append('g'); |
| 200 | + svg.call(d3.zoom().on('zoom', (e) => g.attr('transform', e.transform))); |
| 201 | + |
| 202 | + // 8) Draw elements |
| 203 | + |
| 204 | + // Draw connection lines first |
| 205 | + nodeMap.forEach((node) => { |
| 206 | + if (node.parent && nodeMap.has(node.parent)) { |
| 207 | + const parentY = nodePositions.get(node.parent); |
| 208 | + const childY = nodePositions.get(node.id); |
| 209 | + g.append('line') |
| 210 | + .attr('x1', x(node.ts0)) |
| 211 | + .attr('y1', parentY) |
| 212 | + .attr('x2', x(node.ts0)) |
| 213 | + .attr('y2', childY) |
| 214 | + .attr('stroke', '#ccc') |
| 215 | + .attr('stroke-width', 1); |
| 216 | + } |
| 217 | + }); |
| 218 | + |
| 219 | + // Draw span bars on top of connections |
| 220 | + nodeMap.forEach((node) => { |
| 221 | + const yPos = nodePositions.get(node.id); |
| 222 | + // console.log(node); |
| 223 | + |
| 224 | + // Timeline bar |
| 225 | + g.append('line') |
| 226 | + .attr('x1', x(node.ts0)) |
| 227 | + .attr('y1', yPos) |
| 228 | + .attr('x2', x(node.ts1)) |
| 229 | + .attr('y2', yPos) |
| 230 | + .attr('stroke', node.hasEnd ? '#258' : '#911') |
| 231 | + .attr('stroke-width', 4) |
| 232 | + // .attr('stroke-dasharray', node.hasEnd ? null : "4,4"); |
| 233 | + |
| 234 | + // Label |
| 235 | + g.append('text') |
| 236 | + .attr('x', x(node.ts0) + 5) |
| 237 | + .attr('y', yPos + 16) |
| 238 | + .text(node.name) |
| 239 | + .attr('fill', '#333') |
| 240 | + .attr('font-size', '12px'); |
| 241 | + }); |
| 242 | + |
| 243 | + const xAxis = d3 |
| 244 | + .axisBottom(x) |
| 245 | + .ticks(10) |
| 246 | + .tickFormat((d) => new Date(d).toISOString().slice(11, 23)); |
| 247 | + |
| 248 | + g.append('g') |
| 249 | + .attr('transform', `translate(0,${margin.top - 48})`) |
| 250 | + .call(xAxis) |
| 251 | + .selectAll('path, line') |
| 252 | + .attr('stroke', '#666'); |
| 253 | + })(); |
| 254 | + </script> |
| 255 | + <style> |
| 256 | + body { |
| 257 | + margin: 0; |
| 258 | + font-family: monospace; |
| 259 | + } |
| 260 | + svg { |
| 261 | + display: block; |
| 262 | + background: #fff; |
| 263 | + } |
| 264 | + </style> |
| 265 | + </head> |
| 266 | + <body> |
| 267 | + <!-- if fetch fails, a file-picker will be injected by the script --> |
| 268 | + <svg></svg> |
| 269 | + </body> |
| 270 | +</html> |
0 commit comments