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.
@@ -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
+ };