Skip to content

Commit 8269547

Browse files
committed
wip: span visualiser
1 parent 4e46a77 commit 8269547

File tree

7 files changed

+1536
-1225
lines changed

7 files changed

+1536
-1225
lines changed

index.html

Lines changed: 270 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,270 @@
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>

npmDepsHash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
sha256-lV+4/1+pJp1IpSxd31F3FzHPqsp6AK5pFhfWzdo0orM=
1+
sha256-sUrFNZdpwjBXOaIK+skCBJ8M3zgQqdQrby1kvSQ90X4=

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@
115115
"postversion": "npm install --package-lock-only --ignore-scripts --silent",
116116
"dependencies": "node ./scripts/npmDepsHash.mjs",
117117
"tsx": "tsx",
118-
"test": "node ./scripts/test.mjs",
118+
"test": "node ./scripts/build.mjs && node ./scripts/test.mjs",
119119
"lint": "eslint '{src,tests,scripts,benches}/**/*.{js,mjs,ts,mts,jsx,tsx}'",
120120
"lintfix": "eslint '{src,tests,scripts,benches}/**/*.{js,mjs,ts,mts,jsx,tsx}' --fix",
121121
"lint-shell": "find ./src ./tests ./scripts -type f -regextype posix-extended -regex '.*\\.(sh)' -exec shellcheck {} +",
@@ -137,7 +137,7 @@
137137
},
138138
"devDependencies": {
139139
"@matrixai/errors": "^2.1.3",
140-
"@matrixai/logger": "^4.0.4-alpha.3",
140+
"@matrixai/logger": "^4.0.4-alpha.4",
141141
"@matrixai/exec": "^1.0.3",
142142
"@fast-check/jest": "^2.1.1",
143143
"@swc/core": "1.3.82",
@@ -173,6 +173,6 @@
173173
"typescript": "^5.1.6"
174174
},
175175
"overrides": {
176-
"@matrixai/logger": "^4.0.4-alpha.3"
176+
"@matrixai/logger": "^4.0.4-alpha.4"
177177
}
178178
}

0 commit comments

Comments
 (0)