|
5 | 5 | <title>Span Visualiser</title> |
6 | 6 | <meta name="viewport" content="width=device-width,initial-scale=1" /> |
7 | 7 | <script type="module"> |
8 | | - import * as d3 from 'https://cdn.skypack.dev/d3@7'; |
9 | 8 | import { bases } from 'https://cdn.skypack.dev/multiformats/basics'; |
10 | 9 |
|
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; |
| 10 | + /** |
| 11 | + * @typedef {{ |
| 12 | + * name: string, |
| 13 | + * prefix: string, |
| 14 | + * encode: (input: Uint8Array) => string, |
| 15 | + * decode: (input: string) => Uint8Array |
| 16 | + * }} Codec |
| 17 | + */ |
| 18 | + |
| 19 | + const unixtsSize = 36; |
| 20 | + const msecSize = 12; |
| 21 | + const msecPrecision = 3; |
| 22 | + |
| 23 | + /** |
| 24 | + * Encodes Uint8Array into bit string |
| 25 | + * @param {Uint8Array} bytes |
| 26 | + * @return {string} |
| 27 | + */ |
18 | 28 | function bytes2bits(bytes) { |
19 | | - return Array.from(bytes) |
20 | | - .map((b) => b.toString(2).padStart(8, '0')) |
21 | | - .join(''); |
| 29 | + return [...bytes].map((n) => dec2bits(n, 8)).join(''); |
22 | 30 | } |
23 | | - function fromMultibase(str) { |
24 | | - const codec = basesByPrefix[str[0]]; |
25 | | - if (!codec) return; |
| 31 | + |
| 32 | + /** |
| 33 | + * Encodes positive base 10 numbers to bit string |
| 34 | + * Will output bits in big-endian order |
| 35 | + * @param {number} dec |
| 36 | + * @param {number|undefined} size |
| 37 | + * @return {string} |
| 38 | + */ |
| 39 | + function dec2bits(dec, size) { |
| 40 | + if (dec < 0) throw RangeError('`dec` must be positive'); |
| 41 | + if (size != null) { |
| 42 | + if (size < 0) throw RangeError('`size` must be positive'); |
| 43 | + if (size === 0) return ''; |
| 44 | + dec %= 2 ** size; |
| 45 | + } else { |
| 46 | + size = 0; |
| 47 | + } |
| 48 | + return dec.toString(2).padStart(size, '0'); |
| 49 | + } |
| 50 | + |
| 51 | + /** @type {Record<string,Codec>} */ |
| 52 | + const basesByPrefix = {}; |
| 53 | + for (const k in bases) { |
| 54 | + /** @type {Codec} */ |
| 55 | + const codec = bases[k]; |
| 56 | + basesByPrefix[codec.prefix] = codec; |
| 57 | + } |
| 58 | + |
| 59 | + /** |
| 60 | + * Decodes a multibase encoded ID |
| 61 | + * Do not use this for generic multibase strings |
| 62 | + * @param {string} idString |
| 63 | + * @return {Uint8Array|undefined} |
| 64 | + */ |
| 65 | + function fromMultibase(idString) { |
| 66 | + const prefix = idString[0]; |
| 67 | + const codec = basesByPrefix[prefix]; |
| 68 | + if (codec == null) { |
| 69 | + return; |
| 70 | + } |
| 71 | + /** @type {Uint8Array} */ |
| 72 | + let buffer; |
26 | 73 | try { |
27 | | - return codec.decode(str); |
28 | | - } catch { |
| 74 | + buffer = codec.decode(idString); |
| 75 | + } catch (e) { |
29 | 76 | return; |
30 | 77 | } |
| 78 | + return buffer; |
31 | 79 | } |
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; |
| 80 | + |
| 81 | + /** |
| 82 | + * Converts fixed point tuple to floating point number |
| 83 | + * Size is number of bits allocated for the fractional |
| 84 | + * Precision dictates a fixed number of decimal places for the fractional |
| 85 | + * @param {[number,number]} [integer, fractionalFixed] |
| 86 | + * @param {number} size |
| 87 | + * @param {number|undefined} precision |
| 88 | + * @return number |
| 89 | + */ |
| 90 | + function fromFixedPoint([integer, fractionalFixed], size, precision) { |
| 91 | + /** @type {number} */ |
| 92 | + let fractional; |
| 93 | + if (precision == null) { |
| 94 | + fractional = fractionalFixed / 2 ** size; |
| 95 | + } else { |
| 96 | + fractional = roundPrecise(fractionalFixed / 2 ** size, precision); |
| 97 | + } |
| 98 | + return integer + fractional; |
40 | 99 | } |
41 | 100 |
|
42 | | - //— load JSONL either via fetch or via file input fallback — |
| 101 | + /** |
| 102 | + * Round to number of decimal points |
| 103 | + * @param {number} num |
| 104 | + * @param {number} digits |
| 105 | + * @param {number} base |
| 106 | + * @return {number} |
| 107 | + */ |
| 108 | + function roundPrecise(num, digits = 0, base = 10) { |
| 109 | + const pow = Math.pow(base, digits); |
| 110 | + return Math.round((num + Number.EPSILON) * pow) / pow; |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * @param {Uint8Array} idBytes |
| 115 | + * @return {number} |
| 116 | + */ |
| 117 | + function extractTs(idBytes) { |
| 118 | + // Decode the timestamp from the last id |
| 119 | + // the timestamp bits is 48 bits or 6 bytes |
| 120 | + // this creates a new zero-copy view |
| 121 | + const idTsBytes = idBytes.subarray(0, (unixtsSize + msecSize) / 8); |
| 122 | + const idTsBits = bytes2bits(idTsBytes); |
| 123 | + const unixtsBits = idTsBits.substr(0, unixtsSize); |
| 124 | + const msecBits = idTsBits.substr(unixtsSize, unixtsSize + msecSize); |
| 125 | + const unixts = parseInt(unixtsBits, 2); |
| 126 | + const msec = parseInt(msecBits, 2); |
| 127 | + // Converting from second and subseconds |
| 128 | + return fromFixedPoint([unixts, msec], msecSize, msecPrecision); |
| 129 | + } |
| 130 | + |
| 131 | + globalThis.idUtils = { |
| 132 | + fromMultibase, |
| 133 | + extractTs, |
| 134 | + }; |
| 135 | + </script> |
| 136 | + <script type="module"> |
| 137 | + /** |
| 138 | + * Loads JSONL by asking the user to upload a file |
| 139 | + * @return {Promise<Array<string>>} Array of lines |
| 140 | + */ |
43 | 141 | function loadLines() { |
44 | 142 | return new Promise((resolve) => { |
45 | 143 | const input = document.createElement('input'); |
|
59 | 157 | }); |
60 | 158 | } |
61 | 159 |
|
62 | | - //— main — |
63 | | - (async () => { |
64 | | - const rawLines = await loadLines(); |
| 160 | + globalThis.utils = { |
| 161 | + loadLines, |
| 162 | + }; |
| 163 | + </script> |
| 164 | + <script type="module"> |
| 165 | + import * as d3 from 'https://cdn.skypack.dev/d3@7'; |
| 166 | + |
| 167 | + const main = async () => { |
| 168 | + const rawLines = await utils.loadLines(); |
65 | 169 | const events = rawLines.map((l) => JSON.parse(l)); |
66 | 170 |
|
67 | | - // 1) group by spanId |
68 | | - const evBySpan = new Map(); |
| 171 | + // 1) Group by spanId |
| 172 | + const eventBySpan = new Map(); |
69 | 173 | 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); |
| 174 | + const spanId = e.type === 'start' ? e.id : e.startId; |
| 175 | + if (!eventBySpan.has(spanId)) eventBySpan.set(spanId, []); |
| 176 | + eventBySpan.get(spanId).push(e); |
73 | 177 | } |
74 | 178 |
|
75 | | - // 2) for each span, sort events, pick start/end, record parent |
| 179 | + // 2) For each span, sort events, pick start/end, record parent |
76 | 180 | const spanInfo = {}; |
77 | | - for (const [sid, evts] of evBySpan.entries()) { |
| 181 | + for (const [sid, evts] of eventBySpan.entries()) { |
78 | 182 | evts.sort( |
79 | 183 | (a, b) => |
80 | | - extractTs(a.id || a.startId) - extractTs(b.id || b.startId), |
| 184 | + idUtils.extractTs(idUtils.fromMultibase(a.id || a.startId)) - |
| 185 | + idUtils.extractTs(idUtils.fromMultibase(b.id || b.startId)), |
81 | 186 | ); |
82 | 187 | const startEvt = evts.find((e) => e.type === 'start'); |
83 | | - const stopEvt = evts.find( |
84 | | - (e) => e.type === 'stop' || e.type === 'end', |
85 | | - ); |
| 188 | + const stopEvt = evts.find((e) => e.type === 'stop'); |
86 | 189 | spanInfo[sid] = { |
87 | 190 | id: sid, |
88 | 191 | name: startEvt?.name || sid, |
89 | | - ts0: startEvt ? extractTs(startEvt.id) : Infinity, |
90 | | - ts1: stopEvt ? extractTs(stopEvt.id) : -Infinity, |
| 192 | + ts0: startEvt |
| 193 | + ? idUtils.extractTs(idUtils.fromMultibase(startEvt.id)) * 1000 |
| 194 | + : Infinity, |
| 195 | + ts1: stopEvt |
| 196 | + ? idUtils.extractTs(idUtils.fromMultibase(stopEvt.id)) * 1000 |
| 197 | + : -Infinity, |
91 | 198 | parent: startEvt?.parentSpanId || startEvt?.parentId || null, |
92 | 199 | hasEnd: !!stopEvt, |
93 | 200 | }; |
94 | 201 | } |
95 | 202 |
|
96 | | - // 3) fix open spans → draw to maxTime |
97 | | - const allStarts = Object.values(spanInfo).map((d) => d.ts0); |
| 203 | + // 2.1) [optional] Fix open spans by drawing to maxTime |
| 204 | + const allStarts = Object.values(spanInfo).map((e) => e.ts0); |
98 | 205 | const allEnds = Object.values(spanInfo) |
99 | | - .map((d) => d.ts1) |
100 | | - .filter((d) => d > 0); |
| 206 | + .map((e) => e.ts1) |
| 207 | + .filter((t) => t > 0); |
101 | 208 | const minT = Math.min(...allStarts); |
102 | 209 | const maxT = Math.max(...allEnds); |
103 | 210 | for (const s of Object.values(spanInfo)) { |
104 | 211 | if (s.ts1 < 0) s.ts1 = maxT; |
105 | 212 | } |
106 | 213 |
|
107 | | - // 4) build tree & compute depths |
| 214 | + // 3) Build tree & compute depths |
108 | 215 | const children = {}; |
109 | 216 | for (const sid of Object.keys(spanInfo)) children[sid] = []; |
110 | 217 | for (const s of Object.values(spanInfo)) { |
|
170 | 277 | yPos += verticalSpacing; |
171 | 278 |
|
172 | 279 | if (node.children) { |
173 | | - node.children.forEach(child => layoutTree(child, depth + 1)); |
| 280 | + node.children.forEach((child) => layoutTree(child, depth + 1)); |
174 | 281 | } |
175 | 282 | } |
176 | | - rootNodes.forEach(node => layoutTree(node)); |
| 283 | + rootNodes.forEach((node) => layoutTree(node)); |
177 | 284 |
|
178 | 285 | // 7) Set up scales |
179 | | - const x = d3.scaleLinear() |
| 286 | + const x = d3 |
| 287 | + .scaleLinear() |
180 | 288 | .domain([minT, maxT]) |
181 | 289 | .range([margin.left, W - margin.right]); |
182 | 290 |
|
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 | 291 | // pan/zoom |
199 | 292 | const g = svg.append('g'); |
200 | 293 | svg.call(d3.zoom().on('zoom', (e) => g.attr('transform', e.transform))); |
|
206 | 299 | if (node.parent && nodeMap.has(node.parent)) { |
207 | 300 | const parentY = nodePositions.get(node.parent); |
208 | 301 | const childY = nodePositions.get(node.id); |
209 | | - g.append('line') |
| 302 | + g.append('line') |
210 | 303 | .attr('x1', x(node.ts0)) |
211 | 304 | .attr('y1', parentY) |
212 | 305 | .attr('x2', x(node.ts0)) |
213 | 306 | .attr('y2', childY) |
214 | | - .attr('stroke', '#ccc') |
215 | | - .attr('stroke-width', 1); |
| 307 | + .attr('stroke', '#aaa') |
| 308 | + .attr('stroke-width', 2); |
216 | 309 | } |
217 | 310 | }); |
218 | 311 |
|
219 | 312 | // Draw span bars on top of connections |
220 | 313 | nodeMap.forEach((node) => { |
221 | 314 | const yPos = nodePositions.get(node.id); |
222 | | - // console.log(node); |
223 | 315 |
|
224 | 316 | // Timeline bar |
225 | 317 | g.append('line') |
|
228 | 320 | .attr('x2', x(node.ts1)) |
229 | 321 | .attr('y2', yPos) |
230 | 322 | .attr('stroke', node.hasEnd ? '#258' : '#911') |
231 | | - .attr('stroke-width', 4) |
232 | | - // .attr('stroke-dasharray', node.hasEnd ? null : "4,4"); |
| 323 | + .attr('stroke-width', 4); |
233 | 324 |
|
234 | 325 | // Label |
235 | 326 | g.append('text') |
|
242 | 333 |
|
243 | 334 | const xAxis = d3 |
244 | 335 | .axisBottom(x) |
245 | | - .ticks(10) |
| 336 | + .ticks(20) |
246 | 337 | .tickFormat((d) => new Date(d).toISOString().slice(11, 23)); |
247 | 338 |
|
248 | 339 | g.append('g') |
249 | 340 | .attr('transform', `translate(0,${margin.top - 48})`) |
250 | 341 | .call(xAxis) |
251 | 342 | .selectAll('path, line') |
252 | 343 | .attr('stroke', '#666'); |
253 | | - })(); |
| 344 | + }; |
| 345 | + |
| 346 | + main(); |
254 | 347 | </script> |
255 | 348 | <style> |
256 | 349 | body { |
|
264 | 357 | </style> |
265 | 358 | </head> |
266 | 359 | <body> |
267 | | - <!-- if fetch fails, a file-picker will be injected by the script --> |
268 | 360 | <svg></svg> |
269 | 361 | </body> |
270 | 362 | </html> |
0 commit comments