alignscope 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alignscope/__init__.py +150 -0
- alignscope/_frontend/css/style.css +663 -0
- alignscope/_frontend/index.html +169 -0
- alignscope/_frontend/js/app.js +360 -0
- alignscope/_frontend/js/metrics.js +220 -0
- alignscope/_frontend/js/timeline.js +494 -0
- alignscope/_frontend/js/topology.js +368 -0
- alignscope/adapters.py +169 -0
- alignscope/cli.py +99 -0
- alignscope/detector.py +242 -0
- alignscope/integrations/__init__.py +28 -0
- alignscope/integrations/mlflow_bridge.py +70 -0
- alignscope/integrations/wandb_bridge.py +81 -0
- alignscope/metrics.py +383 -0
- alignscope/patches/__init__.py +50 -0
- alignscope/patches/pettingzoo.py +332 -0
- alignscope/patches/pymarl.py +277 -0
- alignscope/patches/rllib.py +170 -0
- alignscope/sdk.py +606 -0
- alignscope/server.py +298 -0
- alignscope/simulator.py +493 -0
- alignscope-0.1.0.dist-info/METADATA +183 -0
- alignscope-0.1.0.dist-info/RECORD +26 -0
- alignscope-0.1.0.dist-info/WHEEL +4 -0
- alignscope-0.1.0.dist-info/entry_points.txt +2 -0
- alignscope-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlignScope — Defection & Anomaly Timeline (Dynamic)
|
|
3
|
+
*
|
|
4
|
+
* Horizontal timeline synced to the simulation tick.
|
|
5
|
+
*
|
|
6
|
+
* KEY CHANGE vs original: event type rows are built DYNAMICALLY
|
|
7
|
+
* from whatever event types actually arrive in the data — no
|
|
8
|
+
* hardcoded list. Works with any environment automatically:
|
|
9
|
+
*
|
|
10
|
+
* KAZ → only shows "Coalition ↓" row (only type that fires)
|
|
11
|
+
* Competitive → shows "Defection" + "Coalition ↓"
|
|
12
|
+
* Custom env → shows whatever your detector emits
|
|
13
|
+
*
|
|
14
|
+
* The left-side margin auto-expands to fit the longest label.
|
|
15
|
+
* Marker size = severity. Click/hover shows event details.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const TimelineView = {
|
|
19
|
+
canvas: null,
|
|
20
|
+
ctx: null,
|
|
21
|
+
config: null,
|
|
22
|
+
events: [],
|
|
23
|
+
currentTick: 0,
|
|
24
|
+
maxTicks: 500,
|
|
25
|
+
hoveredEvent: null,
|
|
26
|
+
width: 0,
|
|
27
|
+
height: 0,
|
|
28
|
+
dpr: 1,
|
|
29
|
+
|
|
30
|
+
// Visual constants — left margin is computed dynamically
|
|
31
|
+
MARGIN: { top: 16, bottom: 28, right: 20 },
|
|
32
|
+
TRACK_SPACING: 22, // px between track rows
|
|
33
|
+
TRACK_PADDING: 12, // px above first track
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------------ //
|
|
36
|
+
// DYNAMIC type registry — built from incoming event data //
|
|
37
|
+
// Keys are event.type strings, values are assigned on first sight //
|
|
38
|
+
// ------------------------------------------------------------------ //
|
|
39
|
+
|
|
40
|
+
// Default color palette — cycles for unknown types
|
|
41
|
+
_colorPalette: [
|
|
42
|
+
'#dc4a4a', // red — defection
|
|
43
|
+
'#d4a843', // amber — coalition
|
|
44
|
+
'#b06ec7', // purple — stability
|
|
45
|
+
'#c97b3a', // orange — reciprocity
|
|
46
|
+
'#4abe7d', // green
|
|
47
|
+
'#6d9eeb', // blue
|
|
48
|
+
'#e8925a', // coral
|
|
49
|
+
'#5bc0de', // teal
|
|
50
|
+
'#8cc152', // lime
|
|
51
|
+
'#f06292', // pink
|
|
52
|
+
],
|
|
53
|
+
|
|
54
|
+
// Preferred colors for known type names (override palette cycling)
|
|
55
|
+
_knownColors: {
|
|
56
|
+
defection: '#dc4a4a',
|
|
57
|
+
reciprocity_drop: '#c97b3a',
|
|
58
|
+
stability_drop: '#b06ec7',
|
|
59
|
+
coalition_fragmentation: '#d4a843',
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// Human-readable labels for known type names
|
|
63
|
+
_knownLabels: {
|
|
64
|
+
defection: 'Defection',
|
|
65
|
+
reciprocity_drop: 'Reciprocity ↓',
|
|
66
|
+
stability_drop: 'Stability ↓',
|
|
67
|
+
coalition_fragmentation: 'Coalition ↓',
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// Dynamically built: { type_string: { color, label, index } }
|
|
71
|
+
_typeRegistry: {},
|
|
72
|
+
|
|
73
|
+
// ------------------------------------------------------------------ //
|
|
74
|
+
// Helpers //
|
|
75
|
+
// ------------------------------------------------------------------ //
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register a new event type if not yet seen.
|
|
79
|
+
* Returns the registry entry for this type.
|
|
80
|
+
*/
|
|
81
|
+
_registerType(type) {
|
|
82
|
+
if (this._typeRegistry[type]) return this._typeRegistry[type];
|
|
83
|
+
|
|
84
|
+
const index = Object.keys(this._typeRegistry).length;
|
|
85
|
+
const color = this._knownColors[type]
|
|
86
|
+
|| this._colorPalette[index % this._colorPalette.length];
|
|
87
|
+
|
|
88
|
+
// Convert snake_case to "Title ↓" if not a known label
|
|
89
|
+
const label = this._knownLabels[type] || this._formatLabel(type);
|
|
90
|
+
|
|
91
|
+
this._typeRegistry[type] = { color, label, index };
|
|
92
|
+
return this._typeRegistry[type];
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
_formatLabel(type) {
|
|
96
|
+
// snake_case → "Snake case"
|
|
97
|
+
return type
|
|
98
|
+
.replace(/_/g, ' ')
|
|
99
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compute the left margin needed to fit the longest label.
|
|
104
|
+
* Recalculated whenever new types are registered.
|
|
105
|
+
*/
|
|
106
|
+
_computeLeftMargin() {
|
|
107
|
+
const labels = Object.values(this._typeRegistry).map(t => t.label);
|
|
108
|
+
if (labels.length === 0) return 60;
|
|
109
|
+
// Approximate: ~6.5px per character at 10px Inter
|
|
110
|
+
const maxLen = Math.max(...labels.map(l => l.length));
|
|
111
|
+
return Math.max(60, maxLen * 6.5 + 14);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Y position for a given type's track row.
|
|
116
|
+
*/
|
|
117
|
+
_trackY(typeEntry) {
|
|
118
|
+
const left = this._computeLeftMargin();
|
|
119
|
+
const top = this.MARGIN.top;
|
|
120
|
+
return top + this.TRACK_PADDING + typeEntry.index * this.TRACK_SPACING;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// ------------------------------------------------------------------ //
|
|
124
|
+
// Lifecycle //
|
|
125
|
+
// ------------------------------------------------------------------ //
|
|
126
|
+
|
|
127
|
+
init(config) {
|
|
128
|
+
this.config = config;
|
|
129
|
+
this.maxTicks = config.max_ticks || 500;
|
|
130
|
+
this._typeRegistry = {}; // reset on new episode
|
|
131
|
+
|
|
132
|
+
this.canvas = document.getElementById('timeline-canvas');
|
|
133
|
+
if (!this.canvas) { console.error('timeline-canvas not found'); return; }
|
|
134
|
+
this.ctx = this.canvas.getContext('2d');
|
|
135
|
+
this.dpr = window.devicePixelRatio || 1;
|
|
136
|
+
this.zoomLevel = 1.0;
|
|
137
|
+
|
|
138
|
+
this.resize();
|
|
139
|
+
this.draw();
|
|
140
|
+
|
|
141
|
+
this.canvas.addEventListener('mousedown', (e) => this.handleMouseDown(e));
|
|
142
|
+
window.addEventListener('mouseup', (e) => this.handleMouseUp(e));
|
|
143
|
+
this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
|
|
144
|
+
this.canvas.addEventListener('mouseleave', () => this.hideTooltip());
|
|
145
|
+
this.canvas.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
update(data) {
|
|
149
|
+
// Detect episode resets (tick goes backward)
|
|
150
|
+
if (this.lastSeenTick !== undefined && data.tick < this.lastSeenTick - 50) {
|
|
151
|
+
this.currentEpisode = (this.currentEpisode || 1) + 1;
|
|
152
|
+
}
|
|
153
|
+
this.lastSeenTick = data.tick;
|
|
154
|
+
if (this.currentEpisode === undefined) this.currentEpisode = 1;
|
|
155
|
+
|
|
156
|
+
this.currentTick = data.tick;
|
|
157
|
+
|
|
158
|
+
// Dynamically scale max ticks if the simulation runs longer than initially expected
|
|
159
|
+
if (this.currentTick > this.maxTicks) {
|
|
160
|
+
let scaleStep = 500;
|
|
161
|
+
if (this.maxTicks >= 10000) scaleStep = 5000;
|
|
162
|
+
else if (this.maxTicks >= 2000) scaleStep = 1000;
|
|
163
|
+
|
|
164
|
+
while (this.maxTicks < this.currentTick) {
|
|
165
|
+
this.maxTicks += scaleStep;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (data.events && data.events.length > 0) {
|
|
170
|
+
// Register any new event types before accumulating
|
|
171
|
+
for (const event of data.events) {
|
|
172
|
+
event.episode = this.currentEpisode; // Tag event with its episode
|
|
173
|
+
this._registerType(event.type);
|
|
174
|
+
}
|
|
175
|
+
this.events.push(...data.events);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.draw();
|
|
179
|
+
this.updateBadge();
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------------------ //
|
|
183
|
+
// Drawing //
|
|
184
|
+
// ------------------------------------------------------------------ //
|
|
185
|
+
|
|
186
|
+
draw() {
|
|
187
|
+
if (!this.ctx) return;
|
|
188
|
+
|
|
189
|
+
const { ctx, width, height, dpr } = this;
|
|
190
|
+
const left = this._computeLeftMargin();
|
|
191
|
+
const { top, bottom, right } = this.MARGIN;
|
|
192
|
+
const plotWidth = width - left - right;
|
|
193
|
+
const plotHeight = height - top - bottom;
|
|
194
|
+
|
|
195
|
+
ctx.clearRect(0, 0, width * dpr, height * dpr);
|
|
196
|
+
ctx.save();
|
|
197
|
+
ctx.scale(dpr, dpr);
|
|
198
|
+
|
|
199
|
+
// Background
|
|
200
|
+
ctx.fillStyle = '#161922';
|
|
201
|
+
ctx.fillRect(0, 0, width, height);
|
|
202
|
+
|
|
203
|
+
// Axis + grid lines
|
|
204
|
+
this.drawAxis(left, top, plotWidth, plotHeight);
|
|
205
|
+
|
|
206
|
+
// Progress line
|
|
207
|
+
const progressX = left + (this.currentTick / this.maxTicks) * plotWidth;
|
|
208
|
+
ctx.strokeStyle = '#3d4160';
|
|
209
|
+
ctx.lineWidth = 1;
|
|
210
|
+
ctx.setLineDash([3, 3]);
|
|
211
|
+
ctx.beginPath();
|
|
212
|
+
ctx.moveTo(progressX, top);
|
|
213
|
+
ctx.lineTo(progressX, top + plotHeight);
|
|
214
|
+
ctx.stroke();
|
|
215
|
+
ctx.setLineDash([]);
|
|
216
|
+
|
|
217
|
+
// Current tick label
|
|
218
|
+
ctx.fillStyle = '#9ca3af';
|
|
219
|
+
ctx.font = '10px "JetBrains Mono"';
|
|
220
|
+
ctx.textAlign = 'center';
|
|
221
|
+
ctx.fillText(`t=${this.currentTick}`, progressX, top + plotHeight + 18);
|
|
222
|
+
|
|
223
|
+
const numTypes = Object.keys(this._typeRegistry).length;
|
|
224
|
+
|
|
225
|
+
if (numTypes === 0) {
|
|
226
|
+
// No events yet — show a placeholder message
|
|
227
|
+
ctx.fillStyle = '#4b5563';
|
|
228
|
+
ctx.font = '11px Inter';
|
|
229
|
+
ctx.textAlign = 'left';
|
|
230
|
+
ctx.fillText('No events yet — waiting for data…', left + 8, top + plotHeight / 2);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Draw track row labels (left side) — only for seen types
|
|
234
|
+
ctx.textAlign = 'right';
|
|
235
|
+
ctx.font = '10px Inter';
|
|
236
|
+
for (const [type, entry] of Object.entries(this._typeRegistry)) {
|
|
237
|
+
const y = this._trackY(entry);
|
|
238
|
+
ctx.fillStyle = entry.color;
|
|
239
|
+
ctx.globalAlpha = 0.75;
|
|
240
|
+
ctx.fillText(entry.label, left - 6, y + 3);
|
|
241
|
+
ctx.globalAlpha = 1;
|
|
242
|
+
|
|
243
|
+
// Subtle horizontal guide line for this track
|
|
244
|
+
ctx.strokeStyle = entry.color + '18';
|
|
245
|
+
ctx.lineWidth = 1;
|
|
246
|
+
ctx.beginPath();
|
|
247
|
+
ctx.moveTo(left, y);
|
|
248
|
+
ctx.lineTo(left + plotWidth, y);
|
|
249
|
+
ctx.stroke();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Draw event markers
|
|
253
|
+
for (const event of this.events) {
|
|
254
|
+
const entry = this._typeRegistry[event.type];
|
|
255
|
+
if (!entry) continue;
|
|
256
|
+
|
|
257
|
+
const x = left + (event.tick / this.maxTicks) * plotWidth;
|
|
258
|
+
const y = this._trackY(entry);
|
|
259
|
+
const severity = event.severity || 0.5;
|
|
260
|
+
const radius = 3 + severity * 5;
|
|
261
|
+
const color = entry.color;
|
|
262
|
+
|
|
263
|
+
// Glow for high severity
|
|
264
|
+
if (severity > 0.6) {
|
|
265
|
+
ctx.beginPath();
|
|
266
|
+
ctx.arc(x, y, radius + 3, 0, Math.PI * 2);
|
|
267
|
+
ctx.fillStyle = color + '20';
|
|
268
|
+
ctx.fill();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Marker fill
|
|
272
|
+
ctx.beginPath();
|
|
273
|
+
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
274
|
+
ctx.fillStyle = color;
|
|
275
|
+
ctx.globalAlpha = 0.5 + severity * 0.5;
|
|
276
|
+
ctx.fill();
|
|
277
|
+
ctx.globalAlpha = 1;
|
|
278
|
+
|
|
279
|
+
// Marker border
|
|
280
|
+
ctx.strokeStyle = color;
|
|
281
|
+
ctx.lineWidth = 1;
|
|
282
|
+
ctx.stroke();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Faded overlay for future ticks
|
|
286
|
+
if (progressX < left + plotWidth) {
|
|
287
|
+
ctx.fillStyle = 'rgba(15, 17, 23, 0.4)';
|
|
288
|
+
ctx.fillRect(progressX, top, left + plotWidth - progressX, plotHeight);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
ctx.restore();
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
drawAxis(x, y, plotWidth, plotHeight) {
|
|
295
|
+
const ctx = this.ctx;
|
|
296
|
+
|
|
297
|
+
ctx.strokeStyle = '#252836';
|
|
298
|
+
ctx.lineWidth = 1;
|
|
299
|
+
ctx.beginPath();
|
|
300
|
+
ctx.moveTo(x, y + plotHeight);
|
|
301
|
+
ctx.lineTo(x + plotWidth, y + plotHeight);
|
|
302
|
+
ctx.stroke();
|
|
303
|
+
|
|
304
|
+
ctx.fillStyle = '#4b5563';
|
|
305
|
+
ctx.font = '9px "JetBrains Mono"';
|
|
306
|
+
ctx.textAlign = 'center';
|
|
307
|
+
|
|
308
|
+
// Dynamic grid interval based on total timeline length
|
|
309
|
+
let interval = 100;
|
|
310
|
+
if (this.maxTicks >= 10000) interval = 1000;
|
|
311
|
+
else if (this.maxTicks >= 1000) interval = 500;
|
|
312
|
+
else if (this.maxTicks <= 200) interval = 50;
|
|
313
|
+
|
|
314
|
+
const plotWidthRatio = plotWidth / this.maxTicks;
|
|
315
|
+
|
|
316
|
+
// Prevent text overlapping when zoomed out
|
|
317
|
+
while (interval * plotWidthRatio < 30) {
|
|
318
|
+
interval *= 2;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Show finer details when zoomed in
|
|
322
|
+
while (interval * plotWidthRatio > 120 && interval > 1) {
|
|
323
|
+
if (interval === 500) interval = 100;
|
|
324
|
+
else if (interval === 100) interval = 50;
|
|
325
|
+
else if (interval === 50) interval = 10;
|
|
326
|
+
else if (interval === 10) interval = 5;
|
|
327
|
+
else interval /= 2;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (let t = 0; t <= this.maxTicks; t += interval) {
|
|
331
|
+
const tickX = x + (t / this.maxTicks) * plotWidth;
|
|
332
|
+
|
|
333
|
+
ctx.strokeStyle = '#1e2130';
|
|
334
|
+
ctx.lineWidth = 1;
|
|
335
|
+
ctx.beginPath();
|
|
336
|
+
ctx.moveTo(tickX, y);
|
|
337
|
+
ctx.lineTo(tickX, y + plotHeight);
|
|
338
|
+
ctx.stroke();
|
|
339
|
+
|
|
340
|
+
ctx.fillStyle = '#4b5563';
|
|
341
|
+
ctx.fillText(t.toString(), tickX, y + plotHeight + 12);
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
// ------------------------------------------------------------------ //
|
|
346
|
+
// Interaction //
|
|
347
|
+
// ------------------------------------------------------------------ //
|
|
348
|
+
|
|
349
|
+
handleWheel(e) {
|
|
350
|
+
// Only zoom if shift or ctrl is pressed (otherwise scroll page as normal)
|
|
351
|
+
if (!e.shiftKey && !e.ctrlKey) return;
|
|
352
|
+
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
|
|
355
|
+
// Calculate mouse position relative to timeline to maintain focus point
|
|
356
|
+
const container = this.canvas.parentElement;
|
|
357
|
+
const mouseX = e.clientX - container.getBoundingClientRect().left;
|
|
358
|
+
const scrollRatio = (container.scrollLeft + mouseX) / this.width;
|
|
359
|
+
|
|
360
|
+
const zoomDelta = e.deltaY > 0 ? -0.3 : 0.3;
|
|
361
|
+
this.zoomLevel = Math.max(1.0, Math.min(30.0, this.zoomLevel + zoomDelta));
|
|
362
|
+
this.resize();
|
|
363
|
+
|
|
364
|
+
// Restore scroll position so zooming feels centered on mouse
|
|
365
|
+
container.scrollLeft = (scrollRatio * this.width) - mouseX;
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
handleMouseDown(e) {
|
|
369
|
+
this.isDragging = true;
|
|
370
|
+
this.dragStartX = e.clientX;
|
|
371
|
+
this.lastX = e.clientX;
|
|
372
|
+
this.canvas.style.cursor = 'grabbing';
|
|
373
|
+
this.hideTooltip();
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
handleMouseUp(e) {
|
|
377
|
+
if (!this.isDragging) return;
|
|
378
|
+
this.isDragging = false;
|
|
379
|
+
this.canvas.style.cursor = 'pointer';
|
|
380
|
+
|
|
381
|
+
// If it was a clean click (no drag), handle it as a click
|
|
382
|
+
if (Math.abs(e.clientX - this.dragStartX) < 3) {
|
|
383
|
+
const event = this.findEventAtPos(e);
|
|
384
|
+
if (event) this.showTooltip(event, e);
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
handleMouseMove(e) {
|
|
389
|
+
if (this.isDragging) {
|
|
390
|
+
const dx = e.clientX - this.lastX;
|
|
391
|
+
this.canvas.parentElement.scrollLeft -= dx;
|
|
392
|
+
this.lastX = e.clientX;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const event = this.findEventAtPos(e);
|
|
397
|
+
this.canvas.style.cursor = event ? 'pointer' : (this.zoomLevel > 1 ? 'grab' : 'default');
|
|
398
|
+
|
|
399
|
+
if (event && event !== this.hoveredEvent) {
|
|
400
|
+
this.hoveredEvent = event;
|
|
401
|
+
this.showTooltip(event, e);
|
|
402
|
+
} else if (!event) {
|
|
403
|
+
this.hoveredEvent = null;
|
|
404
|
+
this.hideTooltip();
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
findEventAtPos(mouseEvent) {
|
|
409
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
410
|
+
const mx = mouseEvent.clientX - rect.left;
|
|
411
|
+
const my = mouseEvent.clientY - rect.top;
|
|
412
|
+
|
|
413
|
+
const left = this._computeLeftMargin();
|
|
414
|
+
const plotWidth = this.width - left - this.MARGIN.right;
|
|
415
|
+
|
|
416
|
+
for (const event of this.events) {
|
|
417
|
+
const entry = this._typeRegistry[event.type];
|
|
418
|
+
if (!entry) continue;
|
|
419
|
+
|
|
420
|
+
const x = left + (event.tick / this.maxTicks) * plotWidth;
|
|
421
|
+
const y = this._trackY(entry);
|
|
422
|
+
const radius = 3 + (event.severity || 0.5) * 5 + 4;
|
|
423
|
+
|
|
424
|
+
if (Math.hypot(mx - x, my - y) <= radius) return event;
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
showTooltip(event, mouseEvent) {
|
|
430
|
+
const tooltip = document.getElementById('timeline-tooltip');
|
|
431
|
+
const container = document.getElementById('timeline-container');
|
|
432
|
+
const containerRect = container.getBoundingClientRect();
|
|
433
|
+
|
|
434
|
+
tooltip.classList.remove('hidden');
|
|
435
|
+
|
|
436
|
+
const entry = this._typeRegistry[event.type] || {};
|
|
437
|
+
const label = entry.label || event.type;
|
|
438
|
+
const color = entry.color || '#e4e4e7';
|
|
439
|
+
|
|
440
|
+
const epStr = event.episode ? ` | Ep ${event.episode}` : '';
|
|
441
|
+
const agentStr = event.agent_id ? ` | Agent ${event.agent_id}` : '';
|
|
442
|
+
|
|
443
|
+
document.getElementById('tooltip-header').textContent = `${label} at tick ${event.tick}${epStr}${agentStr}`;
|
|
444
|
+
document.getElementById('tooltip-header').style.color = color;
|
|
445
|
+
document.getElementById('tooltip-body').textContent = event.description;
|
|
446
|
+
|
|
447
|
+
// Position tooltip firmly within the container so it doesn't get clipped by overflow:hidden parents
|
|
448
|
+
const hoverX = mouseEvent.clientX - containerRect.left;
|
|
449
|
+
const hoverY = mouseEvent.clientY - containerRect.top;
|
|
450
|
+
|
|
451
|
+
// Offset slightly from cursor so it doesn't block hover events
|
|
452
|
+
tooltip.style.left = Math.min(hoverX + 16, containerRect.width - 250) + 'px';
|
|
453
|
+
tooltip.style.top = Math.max(4, hoverY - tooltip.offsetHeight - 16) + 'px';
|
|
454
|
+
tooltip.style.bottom = 'auto'; // override CSS default bottom: 100%
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
hideTooltip() {
|
|
458
|
+
const el = document.getElementById('timeline-tooltip');
|
|
459
|
+
if (el) el.classList.add('hidden');
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
updateBadge() {
|
|
463
|
+
const el = document.getElementById('timeline-badge');
|
|
464
|
+
if (el) el.textContent = `${this.events.length} event${this.events.length !== 1 ? 's' : ''}`;
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
resize() {
|
|
468
|
+
const container = document.getElementById('timeline-container');
|
|
469
|
+
this.baseWidth = container.clientWidth;
|
|
470
|
+
this.height = container.clientHeight;
|
|
471
|
+
|
|
472
|
+
// Apply zoom multiplier
|
|
473
|
+
this.width = Math.max(this.baseWidth, this.baseWidth * (this.zoomLevel || 1.0));
|
|
474
|
+
|
|
475
|
+
this.canvas.width = this.width * this.dpr;
|
|
476
|
+
this.canvas.height = this.height * this.dpr;
|
|
477
|
+
this.canvas.style.width = this.width + 'px';
|
|
478
|
+
this.canvas.style.height = this.height + 'px';
|
|
479
|
+
|
|
480
|
+
this.draw();
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
reset() {
|
|
484
|
+
this.events = [];
|
|
485
|
+
this.currentTick = 0;
|
|
486
|
+
this.lastSeenTick = 0;
|
|
487
|
+
this.currentEpisode = 1;
|
|
488
|
+
this.hoveredEvent = null;
|
|
489
|
+
this._typeRegistry = {};
|
|
490
|
+
this.hideTooltip();
|
|
491
|
+
this.draw();
|
|
492
|
+
this.updateBadge();
|
|
493
|
+
},
|
|
494
|
+
};
|