@hyperlane-xyz/rebalancer-sim 0.1.1
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.
- package/LICENSE.md +195 -0
- package/README.md +582 -0
- package/dist/BridgeMockController.d.ts +87 -0
- package/dist/BridgeMockController.d.ts.map +1 -0
- package/dist/BridgeMockController.js +300 -0
- package/dist/BridgeMockController.js.map +1 -0
- package/dist/KPICollector.d.ts +81 -0
- package/dist/KPICollector.d.ts.map +1 -0
- package/dist/KPICollector.js +239 -0
- package/dist/KPICollector.js.map +1 -0
- package/dist/MessageTracker.d.ts +82 -0
- package/dist/MessageTracker.d.ts.map +1 -0
- package/dist/MessageTracker.js +213 -0
- package/dist/MessageTracker.js.map +1 -0
- package/dist/RebalancerSimulationHarness.d.ts +72 -0
- package/dist/RebalancerSimulationHarness.d.ts.map +1 -0
- package/dist/RebalancerSimulationHarness.js +217 -0
- package/dist/RebalancerSimulationHarness.js.map +1 -0
- package/dist/ScenarioGenerator.d.ts +50 -0
- package/dist/ScenarioGenerator.d.ts.map +1 -0
- package/dist/ScenarioGenerator.js +326 -0
- package/dist/ScenarioGenerator.js.map +1 -0
- package/dist/ScenarioLoader.d.ts +18 -0
- package/dist/ScenarioLoader.d.ts.map +1 -0
- package/dist/ScenarioLoader.js +59 -0
- package/dist/ScenarioLoader.js.map +1 -0
- package/dist/SimulationDeployment.d.ts +20 -0
- package/dist/SimulationDeployment.d.ts.map +1 -0
- package/dist/SimulationDeployment.js +170 -0
- package/dist/SimulationDeployment.js.map +1 -0
- package/dist/SimulationEngine.d.ts +58 -0
- package/dist/SimulationEngine.d.ts.map +1 -0
- package/dist/SimulationEngine.js +302 -0
- package/dist/SimulationEngine.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/runners/NoOpRebalancer.d.ts +17 -0
- package/dist/runners/NoOpRebalancer.d.ts.map +1 -0
- package/dist/runners/NoOpRebalancer.js +28 -0
- package/dist/runners/NoOpRebalancer.js.map +1 -0
- package/dist/runners/ProductionRebalancerRunner.d.ts +22 -0
- package/dist/runners/ProductionRebalancerRunner.d.ts.map +1 -0
- package/dist/runners/ProductionRebalancerRunner.js +219 -0
- package/dist/runners/ProductionRebalancerRunner.js.map +1 -0
- package/dist/runners/SimpleRunner.d.ts +31 -0
- package/dist/runners/SimpleRunner.d.ts.map +1 -0
- package/dist/runners/SimpleRunner.js +286 -0
- package/dist/runners/SimpleRunner.js.map +1 -0
- package/dist/runners/SimulationRegistry.d.ts +46 -0
- package/dist/runners/SimulationRegistry.d.ts.map +1 -0
- package/dist/runners/SimulationRegistry.js +156 -0
- package/dist/runners/SimulationRegistry.js.map +1 -0
- package/dist/types.d.ts +637 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +158 -0
- package/dist/types.js.map +1 -0
- package/dist/visualizer/HtmlTimelineGenerator.d.ts +6 -0
- package/dist/visualizer/HtmlTimelineGenerator.d.ts.map +1 -0
- package/dist/visualizer/HtmlTimelineGenerator.js +1321 -0
- package/dist/visualizer/HtmlTimelineGenerator.js.map +1 -0
- package/dist/visualizer/index.d.ts +4 -0
- package/dist/visualizer/index.d.ts.map +1 -0
- package/dist/visualizer/index.js +3 -0
- package/dist/visualizer/index.js.map +1 -0
- package/package.json +62 -0
- package/src/BridgeMockController.ts +404 -0
- package/src/KPICollector.ts +304 -0
- package/src/MessageTracker.ts +312 -0
- package/src/RebalancerSimulationHarness.ts +325 -0
- package/src/ScenarioGenerator.ts +433 -0
- package/src/ScenarioLoader.ts +73 -0
- package/src/SimulationDeployment.ts +265 -0
- package/src/SimulationEngine.ts +432 -0
- package/src/index.ts +101 -0
- package/src/runners/NoOpRebalancer.ts +40 -0
- package/src/runners/ProductionRebalancerRunner.ts +289 -0
- package/src/runners/SimpleRunner.ts +382 -0
- package/src/runners/SimulationRegistry.ts +215 -0
- package/src/types.ts +878 -0
- package/src/visualizer/HtmlTimelineGenerator.ts +1341 -0
- package/src/visualizer/index.ts +7 -0
|
@@ -0,0 +1,1321 @@
|
|
|
1
|
+
import { toVisualizationData } from '../types.js';
|
|
2
|
+
const DEFAULT_OPTIONS = {
|
|
3
|
+
width: 1200,
|
|
4
|
+
rowHeight: 150,
|
|
5
|
+
showBalances: true,
|
|
6
|
+
showRebalances: true,
|
|
7
|
+
title: '',
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Generate a standalone HTML timeline visualization
|
|
11
|
+
*/
|
|
12
|
+
export function generateTimelineHtml(results, options = {}, config) {
|
|
13
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
14
|
+
const visualizations = results.map((r) => toVisualizationData(r, config));
|
|
15
|
+
const title = opts.title || `Simulation: ${visualizations[0]?.scenario || 'Unknown'}`;
|
|
16
|
+
// Serialize data for embedding (handle BigInt)
|
|
17
|
+
const serializedData = JSON.stringify(visualizations, (_, value) => (typeof value === 'bigint' ? value.toString() : value), 2);
|
|
18
|
+
return `<!DOCTYPE html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="UTF-8">
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
23
|
+
<title>${escapeHtml(title)}</title>
|
|
24
|
+
<style>
|
|
25
|
+
${getStyles(opts)}
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<div class="container">
|
|
30
|
+
<h1>${escapeHtml(title)}</h1>
|
|
31
|
+
<div id="config-panel"></div>
|
|
32
|
+
<div id="timeline-container"></div>
|
|
33
|
+
<div id="legend"></div>
|
|
34
|
+
<div id="details-panel"></div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<script>
|
|
38
|
+
${getScript(opts)}
|
|
39
|
+
|
|
40
|
+
// Embedded simulation data
|
|
41
|
+
const simulationData = ${serializedData};
|
|
42
|
+
|
|
43
|
+
// Render on load
|
|
44
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
45
|
+
renderVisualization(simulationData);
|
|
46
|
+
});
|
|
47
|
+
</script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>`;
|
|
50
|
+
}
|
|
51
|
+
function escapeHtml(str) {
|
|
52
|
+
return str
|
|
53
|
+
.replace(/&/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"');
|
|
57
|
+
}
|
|
58
|
+
function getStyles(opts) {
|
|
59
|
+
return `
|
|
60
|
+
* {
|
|
61
|
+
box-sizing: border-box;
|
|
62
|
+
margin: 0;
|
|
63
|
+
padding: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
body {
|
|
67
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
68
|
+
background: #1a1a2e;
|
|
69
|
+
color: #e0e0e0;
|
|
70
|
+
padding: 20px;
|
|
71
|
+
line-height: 1.6;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.container {
|
|
75
|
+
max-width: ${opts.width + 150}px;
|
|
76
|
+
margin: 0 auto;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
h1 {
|
|
80
|
+
margin-bottom: 20px;
|
|
81
|
+
color: #fff;
|
|
82
|
+
font-size: 1.5rem;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.rebalancer-section {
|
|
86
|
+
margin-bottom: 40px;
|
|
87
|
+
background: #252542;
|
|
88
|
+
border-radius: 8px;
|
|
89
|
+
padding: 20px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.rebalancer-title {
|
|
93
|
+
font-size: 1.2rem;
|
|
94
|
+
margin-bottom: 15px;
|
|
95
|
+
color: #fff;
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: 10px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.rebalancer-badge {
|
|
102
|
+
font-size: 0.7rem;
|
|
103
|
+
padding: 3px 8px;
|
|
104
|
+
background: #4ecdc4;
|
|
105
|
+
color: #1a1a2e;
|
|
106
|
+
border-radius: 4px;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.kpi-row {
|
|
110
|
+
display: flex;
|
|
111
|
+
gap: 15px;
|
|
112
|
+
margin-bottom: 15px;
|
|
113
|
+
flex-wrap: wrap;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.kpi-card {
|
|
117
|
+
background: #1e1e30;
|
|
118
|
+
padding: 12px 16px;
|
|
119
|
+
border-radius: 6px;
|
|
120
|
+
min-width: 120px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.kpi-card .label {
|
|
124
|
+
font-size: 0.75rem;
|
|
125
|
+
color: #888;
|
|
126
|
+
text-transform: uppercase;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.kpi-card .value {
|
|
130
|
+
font-size: 1.3rem;
|
|
131
|
+
font-weight: bold;
|
|
132
|
+
color: #4ecdc4;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.kpi-card.warning .value {
|
|
136
|
+
color: #f9c74f;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.timeline-wrapper {
|
|
140
|
+
position: relative;
|
|
141
|
+
margin-top: 20px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.timeline-svg {
|
|
145
|
+
background: #1e1e30;
|
|
146
|
+
border-radius: 8px;
|
|
147
|
+
display: block;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.chain-label {
|
|
151
|
+
font-size: 12px;
|
|
152
|
+
fill: #aaa;
|
|
153
|
+
font-family: monospace;
|
|
154
|
+
font-weight: bold;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.balance-label {
|
|
158
|
+
font-size: 9px;
|
|
159
|
+
fill: #666;
|
|
160
|
+
font-family: monospace;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.time-axis-label {
|
|
164
|
+
font-size: 10px;
|
|
165
|
+
fill: #666;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.transfer-group {
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.transfer-group:hover .transfer-bar {
|
|
173
|
+
filter: brightness(1.2);
|
|
174
|
+
stroke: #fff;
|
|
175
|
+
stroke-width: 2;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.transfer-bar {
|
|
179
|
+
transition: filter 0.2s, stroke 0.2s;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.transfer-label {
|
|
183
|
+
font-size: 10px;
|
|
184
|
+
fill: #fff;
|
|
185
|
+
font-weight: bold;
|
|
186
|
+
pointer-events: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.transfer-time-label {
|
|
190
|
+
font-size: 8px;
|
|
191
|
+
fill: #888;
|
|
192
|
+
font-family: monospace;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.start-marker {
|
|
196
|
+
fill: #fff;
|
|
197
|
+
stroke: none;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.end-marker {
|
|
201
|
+
stroke-width: 2;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.rebalance-marker {
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.rebalance-marker:hover .rebalance-bar {
|
|
209
|
+
stroke: #fff;
|
|
210
|
+
stroke-width: 2;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.rebalance-bar {
|
|
214
|
+
transition: stroke 0.2s;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.rebalance-arrow {
|
|
218
|
+
stroke-width: 2;
|
|
219
|
+
stroke-dasharray: 4,2;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.balance-line {
|
|
223
|
+
fill: none;
|
|
224
|
+
stroke-width: 2;
|
|
225
|
+
opacity: 0.7;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.balance-hover-area {
|
|
229
|
+
fill: transparent;
|
|
230
|
+
stroke: transparent;
|
|
231
|
+
stroke-width: 20;
|
|
232
|
+
cursor: crosshair;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#config-panel {
|
|
236
|
+
display: flex;
|
|
237
|
+
gap: 30px;
|
|
238
|
+
margin-bottom: 20px;
|
|
239
|
+
padding: 15px;
|
|
240
|
+
background: #252542;
|
|
241
|
+
border-radius: 8px;
|
|
242
|
+
flex-wrap: wrap;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.config-section {
|
|
246
|
+
display: flex;
|
|
247
|
+
flex-direction: column;
|
|
248
|
+
gap: 6px;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.config-title {
|
|
252
|
+
font-size: 0.75rem;
|
|
253
|
+
color: #888;
|
|
254
|
+
text-transform: uppercase;
|
|
255
|
+
margin-bottom: 4px;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.config-item {
|
|
259
|
+
display: flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
gap: 8px;
|
|
262
|
+
font-size: 0.85rem;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.config-label {
|
|
266
|
+
color: #888;
|
|
267
|
+
min-width: 80px;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.config-value {
|
|
271
|
+
color: #fff;
|
|
272
|
+
font-family: monospace;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#legend {
|
|
276
|
+
display: flex;
|
|
277
|
+
gap: 25px;
|
|
278
|
+
margin-top: 20px;
|
|
279
|
+
flex-wrap: wrap;
|
|
280
|
+
padding: 15px;
|
|
281
|
+
background: #252542;
|
|
282
|
+
border-radius: 8px;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.legend-section {
|
|
286
|
+
display: flex;
|
|
287
|
+
flex-direction: column;
|
|
288
|
+
gap: 8px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.legend-title {
|
|
292
|
+
font-size: 0.75rem;
|
|
293
|
+
color: #888;
|
|
294
|
+
text-transform: uppercase;
|
|
295
|
+
margin-bottom: 4px;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.legend-item {
|
|
299
|
+
display: flex;
|
|
300
|
+
align-items: center;
|
|
301
|
+
gap: 8px;
|
|
302
|
+
font-size: 0.85rem;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.legend-color {
|
|
306
|
+
width: 24px;
|
|
307
|
+
height: 12px;
|
|
308
|
+
border-radius: 2px;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.legend-line {
|
|
312
|
+
width: 24px;
|
|
313
|
+
height: 3px;
|
|
314
|
+
border-radius: 1px;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.legend-marker {
|
|
318
|
+
width: 10px;
|
|
319
|
+
height: 10px;
|
|
320
|
+
border-radius: 50%;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#details-panel {
|
|
324
|
+
margin-top: 20px;
|
|
325
|
+
padding: 15px;
|
|
326
|
+
background: #252542;
|
|
327
|
+
border-radius: 8px;
|
|
328
|
+
display: none;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#details-panel.visible {
|
|
332
|
+
display: block;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#details-panel h3 {
|
|
336
|
+
margin-bottom: 10px;
|
|
337
|
+
color: #fff;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#details-panel .detail-row {
|
|
341
|
+
display: flex;
|
|
342
|
+
gap: 10px;
|
|
343
|
+
margin: 5px 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#details-panel .detail-label {
|
|
347
|
+
color: #888;
|
|
348
|
+
min-width: 100px;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#details-panel .detail-value {
|
|
352
|
+
color: #fff;
|
|
353
|
+
font-family: monospace;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.tooltip {
|
|
357
|
+
position: absolute;
|
|
358
|
+
background: rgba(30, 30, 48, 0.95);
|
|
359
|
+
color: #fff;
|
|
360
|
+
padding: 10px 14px;
|
|
361
|
+
border-radius: 6px;
|
|
362
|
+
font-size: 12px;
|
|
363
|
+
pointer-events: none;
|
|
364
|
+
z-index: 1000;
|
|
365
|
+
max-width: 300px;
|
|
366
|
+
border: 1px solid #444;
|
|
367
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.tooltip strong {
|
|
371
|
+
color: #4ecdc4;
|
|
372
|
+
}
|
|
373
|
+
`;
|
|
374
|
+
}
|
|
375
|
+
function getScript(opts) {
|
|
376
|
+
return `
|
|
377
|
+
const WIDTH = ${opts.width};
|
|
378
|
+
const ROW_HEIGHT = ${opts.rowHeight};
|
|
379
|
+
const SHOW_BALANCES = ${opts.showBalances};
|
|
380
|
+
const SHOW_REBALANCES = ${opts.showRebalances};
|
|
381
|
+
const MARGIN = { top: 50, right: 30, bottom: 40, left: 100 };
|
|
382
|
+
|
|
383
|
+
// Distinct colors for transfers (T1, T2, T3, etc.)
|
|
384
|
+
const TRANSFER_COLORS = [
|
|
385
|
+
'#00b4d8', // cyan
|
|
386
|
+
'#06d6a0', // green
|
|
387
|
+
'#ffd166', // yellow
|
|
388
|
+
'#ef476f', // pink
|
|
389
|
+
'#118ab2', // blue
|
|
390
|
+
'#073b4c', // dark blue
|
|
391
|
+
'#e76f51', // orange
|
|
392
|
+
'#2a9d8f', // teal
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
// Colors for balance curves per chain
|
|
396
|
+
const CHAIN_COLORS = {
|
|
397
|
+
chain1: '#f9c74f', // yellow/gold
|
|
398
|
+
chain2: '#4ecdc4', // teal
|
|
399
|
+
chain3: '#f94144', // red
|
|
400
|
+
chain4: '#90be6d', // green
|
|
401
|
+
chain5: '#577590', // blue-gray
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const REBALANCE_COLOR = '#9b59b6'; // purple
|
|
405
|
+
|
|
406
|
+
function renderVisualization(data) {
|
|
407
|
+
const container = document.getElementById('timeline-container');
|
|
408
|
+
const legend = document.getElementById('legend');
|
|
409
|
+
const configPanel = document.getElementById('config-panel');
|
|
410
|
+
|
|
411
|
+
// Render config panel (from first viz)
|
|
412
|
+
if (data[0]?.config) {
|
|
413
|
+
renderConfig(configPanel, data[0].config, data[0].chains);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Render each rebalancer's results
|
|
417
|
+
data.forEach((viz, index) => {
|
|
418
|
+
const section = document.createElement('div');
|
|
419
|
+
section.className = 'rebalancer-section';
|
|
420
|
+
|
|
421
|
+
// Title
|
|
422
|
+
const titleDiv = document.createElement('div');
|
|
423
|
+
titleDiv.className = 'rebalancer-title';
|
|
424
|
+
titleDiv.innerHTML = '<span>' + viz.rebalancerName + '</span>' +
|
|
425
|
+
'<span class="rebalancer-badge">Rebalancer ' + (index + 1) + '</span>';
|
|
426
|
+
section.appendChild(titleDiv);
|
|
427
|
+
|
|
428
|
+
// KPIs
|
|
429
|
+
section.appendChild(renderKPIs(viz));
|
|
430
|
+
|
|
431
|
+
// Timeline SVG
|
|
432
|
+
section.appendChild(renderTimeline(viz, index));
|
|
433
|
+
|
|
434
|
+
container.appendChild(section);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Legend
|
|
438
|
+
renderLegend(legend, data[0]);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function renderKPIs(viz) {
|
|
442
|
+
const kpis = viz.kpis;
|
|
443
|
+
const div = document.createElement('div');
|
|
444
|
+
div.className = 'kpi-row';
|
|
445
|
+
|
|
446
|
+
const completionClass = kpis.completionRate < 0.95 ? 'warning' : '';
|
|
447
|
+
const latencyClass = kpis.p95Latency > 1000 ? 'warning' : '';
|
|
448
|
+
|
|
449
|
+
div.innerHTML = \`
|
|
450
|
+
<div class="kpi-card \${completionClass}">
|
|
451
|
+
<div class="label">Completion</div>
|
|
452
|
+
<div class="value">\${(kpis.completionRate * 100).toFixed(1)}%</div>
|
|
453
|
+
</div>
|
|
454
|
+
<div class="kpi-card">
|
|
455
|
+
<div class="label">Transfers</div>
|
|
456
|
+
<div class="value">\${kpis.completedTransfers}/\${kpis.totalTransfers}</div>
|
|
457
|
+
</div>
|
|
458
|
+
<div class="kpi-card \${latencyClass}">
|
|
459
|
+
<div class="label">Avg Latency</div>
|
|
460
|
+
<div class="value">\${kpis.averageLatency.toFixed(0)}ms</div>
|
|
461
|
+
</div>
|
|
462
|
+
<div class="kpi-card">
|
|
463
|
+
<div class="label">P95 Latency</div>
|
|
464
|
+
<div class="value">\${kpis.p95Latency.toFixed(0)}ms</div>
|
|
465
|
+
</div>
|
|
466
|
+
<div class="kpi-card">
|
|
467
|
+
<div class="label">Rebalances</div>
|
|
468
|
+
<div class="value">\${kpis.totalRebalances}</div>
|
|
469
|
+
</div>
|
|
470
|
+
\`;
|
|
471
|
+
return div;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderTimeline(viz, vizIndex) {
|
|
475
|
+
const chains = viz.chains;
|
|
476
|
+
const height = MARGIN.top + chains.length * ROW_HEIGHT + MARGIN.bottom;
|
|
477
|
+
const innerWidth = WIDTH - MARGIN.left - MARGIN.right;
|
|
478
|
+
|
|
479
|
+
// Time scale
|
|
480
|
+
const timeExtent = [viz.startTime, viz.endTime];
|
|
481
|
+
const duration = timeExtent[1] - timeExtent[0];
|
|
482
|
+
const xScale = (t) => MARGIN.left + ((t - timeExtent[0]) / duration) * innerWidth;
|
|
483
|
+
|
|
484
|
+
// Create SVG
|
|
485
|
+
const wrapper = document.createElement('div');
|
|
486
|
+
wrapper.className = 'timeline-wrapper';
|
|
487
|
+
|
|
488
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
489
|
+
svg.setAttribute('class', 'timeline-svg');
|
|
490
|
+
svg.setAttribute('width', WIDTH);
|
|
491
|
+
svg.setAttribute('height', height);
|
|
492
|
+
|
|
493
|
+
// Defs for markers
|
|
494
|
+
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
495
|
+
defs.innerHTML = \`
|
|
496
|
+
<marker id="rebalance-arrow-\${vizIndex}" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
|
497
|
+
<polygon points="0 0, 8 3, 0 6" fill="\${REBALANCE_COLOR}"/>
|
|
498
|
+
</marker>
|
|
499
|
+
\`;
|
|
500
|
+
svg.appendChild(defs);
|
|
501
|
+
|
|
502
|
+
// Background and chain rows
|
|
503
|
+
chains.forEach((chain, i) => {
|
|
504
|
+
const y = MARGIN.top + i * ROW_HEIGHT;
|
|
505
|
+
|
|
506
|
+
// Row background
|
|
507
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
508
|
+
rect.setAttribute('x', MARGIN.left);
|
|
509
|
+
rect.setAttribute('y', y);
|
|
510
|
+
rect.setAttribute('width', innerWidth);
|
|
511
|
+
rect.setAttribute('height', ROW_HEIGHT);
|
|
512
|
+
rect.setAttribute('fill', i % 2 === 0 ? '#1a1a2e' : '#1e1e35');
|
|
513
|
+
svg.appendChild(rect);
|
|
514
|
+
|
|
515
|
+
// Chain label
|
|
516
|
+
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
517
|
+
text.setAttribute('class', 'chain-label');
|
|
518
|
+
text.setAttribute('x', MARGIN.left - 15);
|
|
519
|
+
text.setAttribute('y', y + ROW_HEIGHT / 2 + 4);
|
|
520
|
+
text.setAttribute('text-anchor', 'end');
|
|
521
|
+
text.textContent = chain;
|
|
522
|
+
svg.appendChild(text);
|
|
523
|
+
|
|
524
|
+
// Horizontal line at center of row
|
|
525
|
+
const centerLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
526
|
+
centerLine.setAttribute('x1', MARGIN.left);
|
|
527
|
+
centerLine.setAttribute('y1', y + ROW_HEIGHT / 2);
|
|
528
|
+
centerLine.setAttribute('x2', MARGIN.left + innerWidth);
|
|
529
|
+
centerLine.setAttribute('y2', y + ROW_HEIGHT / 2);
|
|
530
|
+
centerLine.setAttribute('stroke', '#333');
|
|
531
|
+
centerLine.setAttribute('stroke-width', '1');
|
|
532
|
+
svg.appendChild(centerLine);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// Time axis
|
|
536
|
+
const tickCount = Math.min(10, Math.ceil(duration / 500));
|
|
537
|
+
for (let i = 0; i <= tickCount; i++) {
|
|
538
|
+
const t = timeExtent[0] + (i / tickCount) * duration;
|
|
539
|
+
const x = xScale(t);
|
|
540
|
+
|
|
541
|
+
// Vertical grid line
|
|
542
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
543
|
+
line.setAttribute('x1', x);
|
|
544
|
+
line.setAttribute('y1', MARGIN.top);
|
|
545
|
+
line.setAttribute('x2', x);
|
|
546
|
+
line.setAttribute('y2', height - MARGIN.bottom);
|
|
547
|
+
line.setAttribute('stroke', '#2a2a45');
|
|
548
|
+
line.setAttribute('stroke-width', '1');
|
|
549
|
+
svg.appendChild(line);
|
|
550
|
+
|
|
551
|
+
// Time label
|
|
552
|
+
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
553
|
+
text.setAttribute('class', 'time-axis-label');
|
|
554
|
+
text.setAttribute('x', x);
|
|
555
|
+
text.setAttribute('y', height - 15);
|
|
556
|
+
text.setAttribute('text-anchor', 'middle');
|
|
557
|
+
text.textContent = ((t - timeExtent[0]) / 1000).toFixed(1) + 's';
|
|
558
|
+
svg.appendChild(text);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Balance curves (render first, behind transfers)
|
|
562
|
+
if (SHOW_BALANCES && viz.balanceTimeline.length > 0) {
|
|
563
|
+
renderBalanceCurves(svg, viz, xScale, chains);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Group transfers by origin chain for vertical stacking
|
|
567
|
+
const transfersByChain = {};
|
|
568
|
+
chains.forEach(c => transfersByChain[c] = []);
|
|
569
|
+
viz.transfers.forEach((t, i) => {
|
|
570
|
+
t._index = i; // Store original index for coloring
|
|
571
|
+
if (transfersByChain[t.origin]) {
|
|
572
|
+
transfersByChain[t.origin].push(t);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Render transfers with distinct colors and labels
|
|
577
|
+
chains.forEach((chain, chainIndex) => {
|
|
578
|
+
const chainY = MARGIN.top + chainIndex * ROW_HEIGHT;
|
|
579
|
+
const transfers = transfersByChain[chain] || [];
|
|
580
|
+
const barHeight = 16;
|
|
581
|
+
const barSpacing = 20;
|
|
582
|
+
|
|
583
|
+
// Calculate usable vertical space for transfer bars (leave room for balance curves and rebalances)
|
|
584
|
+
const minY = chainY + 20; // Top margin within row
|
|
585
|
+
const maxY = chainY + ROW_HEIGHT / 2 + 10; // Stop above center + rebalance area
|
|
586
|
+
const usableHeight = maxY - minY;
|
|
587
|
+
const maxBarsPerColumn = Math.max(1, Math.floor(usableHeight / barSpacing));
|
|
588
|
+
|
|
589
|
+
// Start from top of usable area
|
|
590
|
+
const startY = minY + barHeight / 2;
|
|
591
|
+
|
|
592
|
+
transfers.forEach((transfer, stackIndex) => {
|
|
593
|
+
const color = TRANSFER_COLORS[transfer._index % TRANSFER_COLORS.length];
|
|
594
|
+
// Wrap stackIndex to stay within row boundaries
|
|
595
|
+
const wrappedIndex = stackIndex % maxBarsPerColumn;
|
|
596
|
+
const y = startY + wrappedIndex * barSpacing;
|
|
597
|
+
const startX = xScale(transfer.startTime);
|
|
598
|
+
const endX = transfer.endTime ? xScale(transfer.endTime) : xScale(viz.endTime);
|
|
599
|
+
const width = Math.max(endX - startX, 20);
|
|
600
|
+
|
|
601
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
602
|
+
g.setAttribute('class', 'transfer-group');
|
|
603
|
+
|
|
604
|
+
// Transfer bar
|
|
605
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
606
|
+
rect.setAttribute('class', 'transfer-bar');
|
|
607
|
+
rect.setAttribute('x', startX);
|
|
608
|
+
rect.setAttribute('y', y - barHeight / 2);
|
|
609
|
+
rect.setAttribute('width', width);
|
|
610
|
+
rect.setAttribute('height', barHeight);
|
|
611
|
+
rect.setAttribute('rx', 3);
|
|
612
|
+
rect.setAttribute('fill', color);
|
|
613
|
+
if (transfer.status === 'failed') {
|
|
614
|
+
rect.setAttribute('fill', '#f94144');
|
|
615
|
+
rect.setAttribute('opacity', '0.7');
|
|
616
|
+
} else if (transfer.status === 'pending') {
|
|
617
|
+
rect.setAttribute('fill', color);
|
|
618
|
+
rect.setAttribute('opacity', '0.5');
|
|
619
|
+
rect.setAttribute('stroke', color);
|
|
620
|
+
rect.setAttribute('stroke-width', '2');
|
|
621
|
+
rect.setAttribute('stroke-dasharray', '4,2');
|
|
622
|
+
}
|
|
623
|
+
g.appendChild(rect);
|
|
624
|
+
|
|
625
|
+
// Start marker (circle)
|
|
626
|
+
const startCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
627
|
+
startCircle.setAttribute('class', 'start-marker');
|
|
628
|
+
startCircle.setAttribute('cx', startX);
|
|
629
|
+
startCircle.setAttribute('cy', y);
|
|
630
|
+
startCircle.setAttribute('r', 4);
|
|
631
|
+
startCircle.setAttribute('fill', '#fff');
|
|
632
|
+
g.appendChild(startCircle);
|
|
633
|
+
|
|
634
|
+
// End marker (diamond) if completed
|
|
635
|
+
if (transfer.status === 'completed' && transfer.endTime) {
|
|
636
|
+
const endMarker = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
|
637
|
+
endMarker.setAttribute('class', 'end-marker');
|
|
638
|
+
const ex = endX;
|
|
639
|
+
const ey = y;
|
|
640
|
+
const s = 5;
|
|
641
|
+
endMarker.setAttribute('points', \`\${ex},\${ey-s} \${ex+s},\${ey} \${ex},\${ey+s} \${ex-s},\${ey}\`);
|
|
642
|
+
endMarker.setAttribute('fill', color);
|
|
643
|
+
endMarker.setAttribute('stroke', '#fff');
|
|
644
|
+
g.appendChild(endMarker);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Transfer ID label inside bar
|
|
648
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
649
|
+
label.setAttribute('class', 'transfer-label');
|
|
650
|
+
label.setAttribute('x', startX + 6);
|
|
651
|
+
label.setAttribute('y', y + 4);
|
|
652
|
+
label.textContent = 'T' + (transfer._index + 1);
|
|
653
|
+
g.appendChild(label);
|
|
654
|
+
|
|
655
|
+
// Latency label above bar
|
|
656
|
+
if (transfer.latency) {
|
|
657
|
+
const latencyLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
658
|
+
latencyLabel.setAttribute('class', 'transfer-time-label');
|
|
659
|
+
latencyLabel.setAttribute('x', startX + width / 2);
|
|
660
|
+
latencyLabel.setAttribute('y', y - barHeight / 2 - 3);
|
|
661
|
+
latencyLabel.setAttribute('text-anchor', 'middle');
|
|
662
|
+
latencyLabel.textContent = transfer.latency + 'ms';
|
|
663
|
+
g.appendChild(latencyLabel);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Arrow to destination
|
|
667
|
+
if (transfer.status === 'completed' && transfer.endTime) {
|
|
668
|
+
const destChainIndex = chains.indexOf(transfer.destination);
|
|
669
|
+
if (destChainIndex !== -1 && destChainIndex !== chainIndex) {
|
|
670
|
+
const destY = MARGIN.top + destChainIndex * ROW_HEIGHT + ROW_HEIGHT / 2;
|
|
671
|
+
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
672
|
+
arrow.setAttribute('x1', endX);
|
|
673
|
+
arrow.setAttribute('y1', y);
|
|
674
|
+
arrow.setAttribute('x2', endX);
|
|
675
|
+
arrow.setAttribute('y2', destY > y ? destY - 10 : destY + 10);
|
|
676
|
+
arrow.setAttribute('stroke', color);
|
|
677
|
+
arrow.setAttribute('stroke-width', '2');
|
|
678
|
+
arrow.setAttribute('stroke-dasharray', '4,3');
|
|
679
|
+
arrow.setAttribute('opacity', '0.6');
|
|
680
|
+
g.appendChild(arrow);
|
|
681
|
+
|
|
682
|
+
// Arrow head at destination
|
|
683
|
+
const arrowHead = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
|
684
|
+
const ay = destY > y ? destY - 10 : destY + 10;
|
|
685
|
+
const dir = destY > y ? 1 : -1;
|
|
686
|
+
arrowHead.setAttribute('points', \`\${endX-4},\${ay} \${endX+4},\${ay} \${endX},\${ay + dir * 8}\`);
|
|
687
|
+
arrowHead.setAttribute('fill', color);
|
|
688
|
+
arrowHead.setAttribute('opacity', '0.6');
|
|
689
|
+
g.appendChild(arrowHead);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Event handlers
|
|
694
|
+
g.addEventListener('mouseenter', (e) => {
|
|
695
|
+
// Bring to front by moving to end of parent
|
|
696
|
+
g.parentNode.appendChild(g);
|
|
697
|
+
showTooltip(e, transfer, 'transfer');
|
|
698
|
+
});
|
|
699
|
+
g.addEventListener('mouseleave', hideTooltip);
|
|
700
|
+
g.addEventListener('click', () => showDetails(transfer, 'transfer'));
|
|
701
|
+
|
|
702
|
+
svg.appendChild(g);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// Rebalance bars (similar to transfers but with distinct styling)
|
|
707
|
+
if (SHOW_REBALANCES) {
|
|
708
|
+
// Group rebalances by origin chain for vertical stacking
|
|
709
|
+
const rebalancesByChain = {};
|
|
710
|
+
chains.forEach(c => rebalancesByChain[c] = []);
|
|
711
|
+
viz.rebalances.forEach((r, i) => {
|
|
712
|
+
r._index = i;
|
|
713
|
+
if (rebalancesByChain[r.origin]) {
|
|
714
|
+
rebalancesByChain[r.origin].push(r);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
chains.forEach((chain, chainIndex) => {
|
|
719
|
+
const chainY = MARGIN.top + chainIndex * ROW_HEIGHT;
|
|
720
|
+
const rebalances = rebalancesByChain[chain] || [];
|
|
721
|
+
const barHeight = 12;
|
|
722
|
+
const barSpacing = 16;
|
|
723
|
+
|
|
724
|
+
// Calculate usable vertical space for rebalance bars (below center line)
|
|
725
|
+
const minY = chainY + ROW_HEIGHT / 2 + 15; // Start below center
|
|
726
|
+
const maxY = chainY + ROW_HEIGHT - 20; // Bottom margin within row
|
|
727
|
+
const usableHeight = maxY - minY;
|
|
728
|
+
const maxBarsPerColumn = Math.max(1, Math.floor(usableHeight / barSpacing));
|
|
729
|
+
|
|
730
|
+
// Start from top of rebalance area
|
|
731
|
+
const startY = minY + barHeight / 2;
|
|
732
|
+
|
|
733
|
+
rebalances.forEach((rebalance, stackIndex) => {
|
|
734
|
+
// Wrap stackIndex to stay within row boundaries
|
|
735
|
+
const wrappedIndex = stackIndex % maxBarsPerColumn;
|
|
736
|
+
const y = startY + wrappedIndex * barSpacing;
|
|
737
|
+
const startX = xScale(rebalance.startTime);
|
|
738
|
+
const endX = rebalance.endTime ? xScale(rebalance.endTime) : xScale(viz.endTime);
|
|
739
|
+
const width = Math.max(endX - startX, 20);
|
|
740
|
+
|
|
741
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
742
|
+
g.setAttribute('class', 'rebalance-marker');
|
|
743
|
+
|
|
744
|
+
// Rebalance bar
|
|
745
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
746
|
+
rect.setAttribute('class', 'rebalance-bar');
|
|
747
|
+
rect.setAttribute('x', startX);
|
|
748
|
+
rect.setAttribute('y', y - barHeight / 2);
|
|
749
|
+
rect.setAttribute('width', width);
|
|
750
|
+
rect.setAttribute('height', barHeight);
|
|
751
|
+
rect.setAttribute('rx', 2);
|
|
752
|
+
rect.setAttribute('fill', REBALANCE_COLOR);
|
|
753
|
+
if (rebalance.status === 'failed') {
|
|
754
|
+
rect.setAttribute('fill', '#f94144');
|
|
755
|
+
rect.setAttribute('opacity', '0.7');
|
|
756
|
+
} else if (rebalance.status === 'pending') {
|
|
757
|
+
rect.setAttribute('opacity', '0.5');
|
|
758
|
+
rect.setAttribute('stroke', REBALANCE_COLOR);
|
|
759
|
+
rect.setAttribute('stroke-width', '2');
|
|
760
|
+
rect.setAttribute('stroke-dasharray', '4,2');
|
|
761
|
+
}
|
|
762
|
+
g.appendChild(rect);
|
|
763
|
+
|
|
764
|
+
// Start marker (small circle)
|
|
765
|
+
const startCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
766
|
+
startCircle.setAttribute('cx', startX);
|
|
767
|
+
startCircle.setAttribute('cy', y);
|
|
768
|
+
startCircle.setAttribute('r', 3);
|
|
769
|
+
startCircle.setAttribute('fill', '#fff');
|
|
770
|
+
g.appendChild(startCircle);
|
|
771
|
+
|
|
772
|
+
// End marker (diamond) if completed
|
|
773
|
+
if (rebalance.status === 'completed' && rebalance.endTime) {
|
|
774
|
+
const endMarker = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
|
775
|
+
const ex = endX;
|
|
776
|
+
const ey = y;
|
|
777
|
+
const s = 4;
|
|
778
|
+
endMarker.setAttribute('points', \`\${ex},\${ey-s} \${ex+s},\${ey} \${ex},\${ey+s} \${ex-s},\${ey}\`);
|
|
779
|
+
endMarker.setAttribute('fill', REBALANCE_COLOR);
|
|
780
|
+
endMarker.setAttribute('stroke', '#fff');
|
|
781
|
+
g.appendChild(endMarker);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// R label inside bar
|
|
785
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
786
|
+
label.setAttribute('x', startX + 5);
|
|
787
|
+
label.setAttribute('y', y + 3);
|
|
788
|
+
label.setAttribute('fill', '#fff');
|
|
789
|
+
label.setAttribute('font-size', '8');
|
|
790
|
+
label.setAttribute('font-weight', 'bold');
|
|
791
|
+
label.textContent = 'R' + (rebalance._index + 1);
|
|
792
|
+
g.appendChild(label);
|
|
793
|
+
|
|
794
|
+
// Latency label above bar
|
|
795
|
+
if (rebalance.latency) {
|
|
796
|
+
const latencyLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
797
|
+
latencyLabel.setAttribute('class', 'transfer-time-label');
|
|
798
|
+
latencyLabel.setAttribute('x', startX + width / 2);
|
|
799
|
+
latencyLabel.setAttribute('y', y - barHeight / 2 - 3);
|
|
800
|
+
latencyLabel.setAttribute('text-anchor', 'middle');
|
|
801
|
+
latencyLabel.textContent = rebalance.latency + 'ms';
|
|
802
|
+
g.appendChild(latencyLabel);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Arrow to destination chain
|
|
806
|
+
const destChainIndex = chains.indexOf(rebalance.destination);
|
|
807
|
+
if (destChainIndex !== -1 && destChainIndex !== chainIndex && rebalance.endTime) {
|
|
808
|
+
const destY = MARGIN.top + destChainIndex * ROW_HEIGHT + ROW_HEIGHT / 2 + 20;
|
|
809
|
+
const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
810
|
+
arrow.setAttribute('class', 'rebalance-arrow');
|
|
811
|
+
arrow.setAttribute('x1', endX);
|
|
812
|
+
arrow.setAttribute('y1', y);
|
|
813
|
+
arrow.setAttribute('x2', endX);
|
|
814
|
+
arrow.setAttribute('y2', destY > y ? destY - 10 : destY + 10);
|
|
815
|
+
arrow.setAttribute('stroke', REBALANCE_COLOR);
|
|
816
|
+
arrow.setAttribute('marker-end', 'url(#rebalance-arrow-' + vizIndex + ')');
|
|
817
|
+
g.appendChild(arrow);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
g.addEventListener('mouseenter', (e) => {
|
|
821
|
+
// Bring to front by moving to end of parent
|
|
822
|
+
g.parentNode.appendChild(g);
|
|
823
|
+
showTooltip(e, rebalance, 'rebalance');
|
|
824
|
+
});
|
|
825
|
+
g.addEventListener('mouseleave', hideTooltip);
|
|
826
|
+
g.addEventListener('click', () => showDetails(rebalance, 'rebalance'));
|
|
827
|
+
|
|
828
|
+
svg.appendChild(g);
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Setup balance hover listeners after all elements are added
|
|
834
|
+
setupBalanceHoverListeners(svg, xScale, viz.startTime);
|
|
835
|
+
|
|
836
|
+
wrapper.appendChild(svg);
|
|
837
|
+
return wrapper;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function setupBalanceHoverListeners(svg, xScale, startTime) {
|
|
841
|
+
const hoverAreas = svg.querySelectorAll('.balance-hover-area');
|
|
842
|
+
hoverAreas.forEach(hoverPath => {
|
|
843
|
+
hoverPath.addEventListener('mousemove', (e) => {
|
|
844
|
+
const chain = hoverPath.getAttribute('data-chain');
|
|
845
|
+
const timeline = JSON.parse(hoverPath.getAttribute('data-timeline'));
|
|
846
|
+
const totalBalance = BigInt(hoverPath.getAttribute('data-total-balance'));
|
|
847
|
+
const simStartTime = parseInt(hoverPath.getAttribute('data-start-time'));
|
|
848
|
+
|
|
849
|
+
// Get mouse X position relative to SVG
|
|
850
|
+
const svgRect = svg.getBoundingClientRect();
|
|
851
|
+
const mouseX = e.clientX - svgRect.left;
|
|
852
|
+
|
|
853
|
+
// Find the timestamp at this X position (inverse of xScale)
|
|
854
|
+
const innerWidth = svg.clientWidth - MARGIN.left - MARGIN.right;
|
|
855
|
+
const duration = timeline[timeline.length - 1].timestamp - simStartTime;
|
|
856
|
+
const timestamp = simStartTime + ((mouseX - MARGIN.left) / innerWidth) * duration;
|
|
857
|
+
|
|
858
|
+
// Find the balance at this timestamp (step function - find last point before timestamp)
|
|
859
|
+
let balance = BigInt(timeline[0].balance);
|
|
860
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
861
|
+
if (timeline[i].timestamp <= timestamp) {
|
|
862
|
+
balance = BigInt(timeline[i].balance);
|
|
863
|
+
} else {
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Calculate percentage of total
|
|
869
|
+
const percentage = totalBalance > 0n
|
|
870
|
+
? (Number(balance) / Number(totalBalance) * 100).toFixed(1)
|
|
871
|
+
: '0.0';
|
|
872
|
+
|
|
873
|
+
showTooltip(e, {
|
|
874
|
+
chain,
|
|
875
|
+
balance,
|
|
876
|
+
totalBalance,
|
|
877
|
+
timestamp,
|
|
878
|
+
startTime: simStartTime,
|
|
879
|
+
percentage
|
|
880
|
+
}, 'balance');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
hoverPath.addEventListener('mouseleave', hideTooltip);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function renderBalanceCurves(svg, viz, xScale, chains) {
|
|
888
|
+
// Compute balance curve from transfer/rebalance events for instant visual feedback
|
|
889
|
+
// Transfer start: origin +amount (user deposits collateral)
|
|
890
|
+
// Transfer complete: destination -amount (collateral released to recipient)
|
|
891
|
+
// Rebalance start: origin +amount (rebalancer deposits)
|
|
892
|
+
// Rebalance complete: destination -amount (collateral released)
|
|
893
|
+
|
|
894
|
+
// Get initial balances from first snapshot
|
|
895
|
+
const initialSnapshot = viz.balanceTimeline[0];
|
|
896
|
+
if (!initialSnapshot) return;
|
|
897
|
+
|
|
898
|
+
// Build event-driven balance timeline
|
|
899
|
+
const balanceEvents = [];
|
|
900
|
+
|
|
901
|
+
// Process transfers - instant balance changes at start and end
|
|
902
|
+
viz.transfers.forEach(t => {
|
|
903
|
+
const amount = BigInt(t.amount);
|
|
904
|
+
// Transfer START: origin receives deposit (+amount)
|
|
905
|
+
balanceEvents.push({
|
|
906
|
+
timestamp: t.startTime,
|
|
907
|
+
chain: t.origin,
|
|
908
|
+
delta: amount,
|
|
909
|
+
});
|
|
910
|
+
// Transfer COMPLETE: destination releases to recipient (-amount)
|
|
911
|
+
if (t.endTime && t.status === 'completed') {
|
|
912
|
+
balanceEvents.push({
|
|
913
|
+
timestamp: t.endTime,
|
|
914
|
+
chain: t.destination,
|
|
915
|
+
delta: -amount,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// Process rebalances - INVERSE of transfers (moving liquidity)
|
|
921
|
+
viz.rebalances.forEach(r => {
|
|
922
|
+
const amount = BigInt(r.amount);
|
|
923
|
+
// Rebalance START: origin SENDS funds away (-amount)
|
|
924
|
+
balanceEvents.push({
|
|
925
|
+
timestamp: r.startTime,
|
|
926
|
+
chain: r.origin,
|
|
927
|
+
delta: -amount,
|
|
928
|
+
});
|
|
929
|
+
// Rebalance COMPLETE: destination RECEIVES funds (+amount)
|
|
930
|
+
if (r.endTime && r.status === 'completed') {
|
|
931
|
+
balanceEvents.push({
|
|
932
|
+
timestamp: r.endTime,
|
|
933
|
+
chain: r.destination,
|
|
934
|
+
delta: amount,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// Sort events by timestamp
|
|
940
|
+
balanceEvents.sort((a, b) => a.timestamp - b.timestamp);
|
|
941
|
+
|
|
942
|
+
// Build per-chain balance timelines
|
|
943
|
+
const chainBalances = {};
|
|
944
|
+
const chainTimelines = {};
|
|
945
|
+
chains.forEach(chain => {
|
|
946
|
+
chainBalances[chain] = BigInt(initialSnapshot.balances[chain] || '0');
|
|
947
|
+
chainTimelines[chain] = [{ timestamp: viz.startTime, balance: chainBalances[chain] }];
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// Apply deltas to build timeline
|
|
951
|
+
balanceEvents.forEach(event => {
|
|
952
|
+
if (event.delta !== undefined) {
|
|
953
|
+
chainBalances[event.chain] += event.delta;
|
|
954
|
+
chainTimelines[event.chain].push({
|
|
955
|
+
timestamp: event.timestamp,
|
|
956
|
+
balance: chainBalances[event.chain],
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
// Add final state
|
|
962
|
+
chains.forEach(chain => {
|
|
963
|
+
chainTimelines[chain].push({
|
|
964
|
+
timestamp: viz.endTime,
|
|
965
|
+
balance: chainBalances[chain],
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// Find global min/max for scaling
|
|
970
|
+
let minBalance = BigInt('999999999999999999999999999');
|
|
971
|
+
let maxBalance = 0n;
|
|
972
|
+
chains.forEach(chain => {
|
|
973
|
+
chainTimelines[chain].forEach(pt => {
|
|
974
|
+
if (pt.balance > maxBalance) maxBalance = pt.balance;
|
|
975
|
+
if (pt.balance < minBalance) minBalance = pt.balance;
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
if (maxBalance === 0n) return;
|
|
980
|
+
const balanceRange = maxBalance - minBalance || 1n;
|
|
981
|
+
|
|
982
|
+
chains.forEach((chain, chainIndex) => {
|
|
983
|
+
const chainY = MARGIN.top + chainIndex * ROW_HEIGHT;
|
|
984
|
+
const curveTop = chainY + 15;
|
|
985
|
+
const curveBottom = chainY + ROW_HEIGHT - 15;
|
|
986
|
+
const curveHeight = curveBottom - curveTop;
|
|
987
|
+
const color = CHAIN_COLORS[chain] || TRANSFER_COLORS[chainIndex % TRANSFER_COLORS.length];
|
|
988
|
+
|
|
989
|
+
// Build path data from event-driven timeline
|
|
990
|
+
const timeline = chainTimelines[chain];
|
|
991
|
+
const points = timeline.map(pt => {
|
|
992
|
+
const x = xScale(pt.timestamp);
|
|
993
|
+
const balance = pt.balance;
|
|
994
|
+
// Scale: high balance = top (low y), low balance = bottom (high y)
|
|
995
|
+
const normalizedY = balanceRange > 0n
|
|
996
|
+
? Number((balance - minBalance) * BigInt(Math.floor(curveHeight * 100)) / balanceRange) / 100
|
|
997
|
+
: curveHeight / 2;
|
|
998
|
+
const y = curveBottom - normalizedY;
|
|
999
|
+
return { x, y, balance };
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// Line path (step function for clearer visualization)
|
|
1003
|
+
let pathD = 'M' + points[0].x + ',' + points[0].y;
|
|
1004
|
+
for (let i = 1; i < points.length; i++) {
|
|
1005
|
+
// Horizontal then vertical for step effect
|
|
1006
|
+
pathD += ' L' + points[i].x + ',' + points[i-1].y;
|
|
1007
|
+
pathD += ' L' + points[i].x + ',' + points[i].y;
|
|
1008
|
+
}
|
|
1009
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1010
|
+
path.setAttribute('class', 'balance-line');
|
|
1011
|
+
path.setAttribute('d', pathD);
|
|
1012
|
+
path.setAttribute('stroke', color);
|
|
1013
|
+
svg.appendChild(path);
|
|
1014
|
+
|
|
1015
|
+
// Invisible hover area for tooltips (wider stroke for easier hovering)
|
|
1016
|
+
const hoverPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1017
|
+
hoverPath.setAttribute('class', 'balance-hover-area');
|
|
1018
|
+
hoverPath.setAttribute('d', pathD);
|
|
1019
|
+
hoverPath.setAttribute('data-chain', chain);
|
|
1020
|
+
hoverPath.setAttribute('data-timeline', JSON.stringify(timeline.map(pt => ({
|
|
1021
|
+
timestamp: pt.timestamp,
|
|
1022
|
+
balance: pt.balance.toString()
|
|
1023
|
+
}))));
|
|
1024
|
+
hoverPath.setAttribute('data-total-balance', maxBalance.toString());
|
|
1025
|
+
hoverPath.setAttribute('data-start-time', viz.startTime.toString());
|
|
1026
|
+
svg.appendChild(hoverPath);
|
|
1027
|
+
|
|
1028
|
+
// Balance labels (start and end values)
|
|
1029
|
+
const startBal = points[0].balance;
|
|
1030
|
+
const endBal = points[points.length - 1].balance;
|
|
1031
|
+
|
|
1032
|
+
const startLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
1033
|
+
startLabel.setAttribute('class', 'balance-label');
|
|
1034
|
+
startLabel.setAttribute('x', points[0].x + 3);
|
|
1035
|
+
startLabel.setAttribute('y', points[0].y - 3);
|
|
1036
|
+
startLabel.textContent = formatBalanceShort(startBal);
|
|
1037
|
+
startLabel.setAttribute('fill', color);
|
|
1038
|
+
svg.appendChild(startLabel);
|
|
1039
|
+
|
|
1040
|
+
const endLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
1041
|
+
endLabel.setAttribute('class', 'balance-label');
|
|
1042
|
+
endLabel.setAttribute('x', points[points.length-1].x - 3);
|
|
1043
|
+
endLabel.setAttribute('y', points[points.length-1].y - 3);
|
|
1044
|
+
endLabel.setAttribute('text-anchor', 'end');
|
|
1045
|
+
endLabel.textContent = formatBalanceShort(endBal);
|
|
1046
|
+
endLabel.setAttribute('fill', color);
|
|
1047
|
+
svg.appendChild(endLabel);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function renderLegend(container, viz) {
|
|
1052
|
+
const transferCount = viz.transfers.length;
|
|
1053
|
+
|
|
1054
|
+
let transferItems = '';
|
|
1055
|
+
for (let i = 0; i < transferCount; i++) {
|
|
1056
|
+
const t = viz.transfers[i];
|
|
1057
|
+
const color = TRANSFER_COLORS[i % TRANSFER_COLORS.length];
|
|
1058
|
+
transferItems += \`
|
|
1059
|
+
<div class="legend-item">
|
|
1060
|
+
<div class="legend-color" style="background: \${color}"></div>
|
|
1061
|
+
<span>T\${i + 1}: \${t.origin} → \${t.destination} (\${formatBalanceShort(BigInt(t.amount))})</span>
|
|
1062
|
+
</div>
|
|
1063
|
+
\`;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
let chainItems = '';
|
|
1067
|
+
viz.chains.forEach(chain => {
|
|
1068
|
+
const color = CHAIN_COLORS[chain] || '#888';
|
|
1069
|
+
chainItems += \`
|
|
1070
|
+
<div class="legend-item">
|
|
1071
|
+
<div class="legend-line" style="background: \${color}"></div>
|
|
1072
|
+
<span>\${chain} collateral balance</span>
|
|
1073
|
+
</div>
|
|
1074
|
+
\`;
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
container.innerHTML = \`
|
|
1078
|
+
<div class="legend-section">
|
|
1079
|
+
<div class="legend-title">Transfers</div>
|
|
1080
|
+
\${transferItems}
|
|
1081
|
+
</div>
|
|
1082
|
+
<div class="legend-section">
|
|
1083
|
+
<div class="legend-title">Markers</div>
|
|
1084
|
+
<div class="legend-item">
|
|
1085
|
+
<div class="legend-marker" style="background: #fff"></div>
|
|
1086
|
+
<span>Transfer start</span>
|
|
1087
|
+
</div>
|
|
1088
|
+
<div class="legend-item">
|
|
1089
|
+
<div class="legend-marker" style="background: #4ecdc4; transform: rotate(45deg)"></div>
|
|
1090
|
+
<span>Transfer delivered</span>
|
|
1091
|
+
</div>
|
|
1092
|
+
<div class="legend-item">
|
|
1093
|
+
<div class="legend-marker" style="background: \${REBALANCE_COLOR}"></div>
|
|
1094
|
+
<span>Rebalance (R)</span>
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
1097
|
+
<div class="legend-section">
|
|
1098
|
+
<div class="legend-title">Balance Curves</div>
|
|
1099
|
+
\${chainItems}
|
|
1100
|
+
</div>
|
|
1101
|
+
\`;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function renderConfig(container, config, chains) {
|
|
1105
|
+
if (!config) {
|
|
1106
|
+
container.style.display = 'none';
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Scenario metadata section
|
|
1111
|
+
let scenarioHtml = '';
|
|
1112
|
+
if (config.description) {
|
|
1113
|
+
scenarioHtml += \`
|
|
1114
|
+
<div class="config-item" style="flex-direction: column; align-items: flex-start;">
|
|
1115
|
+
<span class="config-label">Description:</span>
|
|
1116
|
+
<span class="config-value" style="white-space: normal; margin-top: 4px;">\${config.description}</span>
|
|
1117
|
+
</div>
|
|
1118
|
+
\`;
|
|
1119
|
+
}
|
|
1120
|
+
if (config.expectedBehavior) {
|
|
1121
|
+
scenarioHtml += \`
|
|
1122
|
+
<div class="config-item" style="flex-direction: column; align-items: flex-start; margin-top: 8px;">
|
|
1123
|
+
<span class="config-label">Expected Behavior:</span>
|
|
1124
|
+
<span class="config-value expected-behavior" style="white-space: pre-wrap; margin-top: 4px; font-size: 0.8rem; color: #aaa;">\${config.expectedBehavior}</span>
|
|
1125
|
+
</div>
|
|
1126
|
+
\`;
|
|
1127
|
+
}
|
|
1128
|
+
if (config.transferCount !== undefined || config.duration !== undefined) {
|
|
1129
|
+
scenarioHtml += \`
|
|
1130
|
+
<div class="config-item" style="margin-top: 8px;">
|
|
1131
|
+
\${config.transferCount !== undefined ? \`<span><b>\${config.transferCount}</b> transfers</span>\` : ''}
|
|
1132
|
+
\${config.duration !== undefined ? \`<span style="margin-left: 15px;"><b>\${(config.duration / 1000).toFixed(1)}s</b> duration</span>\` : ''}
|
|
1133
|
+
</div>
|
|
1134
|
+
\`;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
let targetHtml = '';
|
|
1138
|
+
if (config.targetWeights) {
|
|
1139
|
+
chains.forEach(chain => {
|
|
1140
|
+
const weight = config.targetWeights[chain] || 0;
|
|
1141
|
+
const tolerance = config.tolerances?.[chain] || 0;
|
|
1142
|
+
targetHtml += \`
|
|
1143
|
+
<div class="config-item">
|
|
1144
|
+
<span class="config-label">\${chain}:</span>
|
|
1145
|
+
<span class="config-value">\${weight}% ± \${tolerance}%</span>
|
|
1146
|
+
</div>
|
|
1147
|
+
\`;
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
let timingHtml = '';
|
|
1152
|
+
if (config.userTransferDelay !== undefined) {
|
|
1153
|
+
timingHtml += \`
|
|
1154
|
+
<div class="config-item">
|
|
1155
|
+
<span class="config-label">User xfer:</span>
|
|
1156
|
+
<span class="config-value">\${config.userTransferDelay}ms</span>
|
|
1157
|
+
</div>
|
|
1158
|
+
\`;
|
|
1159
|
+
}
|
|
1160
|
+
if (config.bridgeDeliveryDelay !== undefined) {
|
|
1161
|
+
timingHtml += \`
|
|
1162
|
+
<div class="config-item">
|
|
1163
|
+
<span class="config-label">Rebal bridge:</span>
|
|
1164
|
+
<span class="config-value">\${config.bridgeDeliveryDelay}ms</span>
|
|
1165
|
+
</div>
|
|
1166
|
+
\`;
|
|
1167
|
+
}
|
|
1168
|
+
if (config.rebalancerPollingFrequency !== undefined) {
|
|
1169
|
+
timingHtml += \`
|
|
1170
|
+
<div class="config-item">
|
|
1171
|
+
<span class="config-label">Rebal poll:</span>
|
|
1172
|
+
<span class="config-value">\${config.rebalancerPollingFrequency}ms</span>
|
|
1173
|
+
</div>
|
|
1174
|
+
\`;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
let initialHtml = '';
|
|
1178
|
+
if (config.initialCollateral) {
|
|
1179
|
+
chains.forEach(chain => {
|
|
1180
|
+
const initial = config.initialCollateral[chain];
|
|
1181
|
+
if (initial) {
|
|
1182
|
+
const formatted = formatBalanceShort(BigInt(initial));
|
|
1183
|
+
initialHtml += \`
|
|
1184
|
+
<div class="config-item">
|
|
1185
|
+
<span class="config-label">\${chain}:</span>
|
|
1186
|
+
<span class="config-value">\${formatted} tokens</span>
|
|
1187
|
+
</div>
|
|
1188
|
+
\`;
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
container.innerHTML = \`
|
|
1194
|
+
\${scenarioHtml ? \`
|
|
1195
|
+
<div class="config-section scenario-section" style="flex-basis: 100%; margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #3a3a5a;">
|
|
1196
|
+
<div class="config-title">Scenario</div>
|
|
1197
|
+
\${scenarioHtml}
|
|
1198
|
+
</div>
|
|
1199
|
+
\` : ''}
|
|
1200
|
+
\${targetHtml ? \`
|
|
1201
|
+
<div class="config-section">
|
|
1202
|
+
<div class="config-title">Target Weights</div>
|
|
1203
|
+
\${targetHtml}
|
|
1204
|
+
</div>
|
|
1205
|
+
\` : ''}
|
|
1206
|
+
\${timingHtml ? \`
|
|
1207
|
+
<div class="config-section">
|
|
1208
|
+
<div class="config-title">Timing</div>
|
|
1209
|
+
\${timingHtml}
|
|
1210
|
+
</div>
|
|
1211
|
+
\` : ''}
|
|
1212
|
+
\${initialHtml ? \`
|
|
1213
|
+
<div class="config-section">
|
|
1214
|
+
<div class="config-title">Initial Collateral</div>
|
|
1215
|
+
\${initialHtml}
|
|
1216
|
+
</div>
|
|
1217
|
+
\` : ''}
|
|
1218
|
+
\`;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
let tooltipEl = null;
|
|
1222
|
+
|
|
1223
|
+
function showTooltip(event, data, type) {
|
|
1224
|
+
if (!tooltipEl) {
|
|
1225
|
+
tooltipEl = document.createElement('div');
|
|
1226
|
+
tooltipEl.className = 'tooltip';
|
|
1227
|
+
document.body.appendChild(tooltipEl);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
let content = '';
|
|
1231
|
+
if (type === 'transfer') {
|
|
1232
|
+
const status = data.status === 'completed' ? '✓ Delivered' :
|
|
1233
|
+
data.status === 'failed' ? '✗ Failed' : '⏳ Pending';
|
|
1234
|
+
content = \`
|
|
1235
|
+
<strong>Transfer T\${data._index + 1}</strong><br>
|
|
1236
|
+
<b>Route:</b> \${data.origin} → \${data.destination}<br>
|
|
1237
|
+
<b>Amount:</b> \${formatAmount(data.amount)}<br>
|
|
1238
|
+
<b>Latency:</b> \${data.latency ? data.latency + 'ms' : 'N/A'}<br>
|
|
1239
|
+
<b>Status:</b> \${status}
|
|
1240
|
+
\`;
|
|
1241
|
+
} else if (type === 'rebalance') {
|
|
1242
|
+
const status = data.status === 'completed' ? '✓ Delivered' :
|
|
1243
|
+
data.status === 'failed' ? '✗ Failed' : '⏳ Pending';
|
|
1244
|
+
content = \`
|
|
1245
|
+
<strong>Rebalance R\${data._index + 1}</strong><br>
|
|
1246
|
+
<b>Route:</b> \${data.origin} → \${data.destination}<br>
|
|
1247
|
+
<b>Amount:</b> \${formatAmount(data.amount)}<br>
|
|
1248
|
+
<b>Latency:</b> \${data.latency ? data.latency + 'ms' : 'N/A'}<br>
|
|
1249
|
+
<b>Status:</b> \${status}
|
|
1250
|
+
\`;
|
|
1251
|
+
} else if (type === 'balance') {
|
|
1252
|
+
content = \`
|
|
1253
|
+
<strong>\${data.chain} Collateral</strong><br>
|
|
1254
|
+
<b>Balance:</b> \${formatBalanceShort(data.balance)} tokens<br>
|
|
1255
|
+
<b>Share:</b> \${data.percentage}% of total<br>
|
|
1256
|
+
<b>Time:</b> \${((data.timestamp - data.startTime) / 1000).toFixed(2)}s
|
|
1257
|
+
\`;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
tooltipEl.innerHTML = content;
|
|
1261
|
+
tooltipEl.style.left = (event.pageX + 15) + 'px';
|
|
1262
|
+
tooltipEl.style.top = (event.pageY + 15) + 'px';
|
|
1263
|
+
tooltipEl.style.display = 'block';
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function hideTooltip() {
|
|
1267
|
+
if (tooltipEl) {
|
|
1268
|
+
tooltipEl.style.display = 'none';
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function showDetails(data, type) {
|
|
1273
|
+
const panel = document.getElementById('details-panel');
|
|
1274
|
+
panel.classList.add('visible');
|
|
1275
|
+
|
|
1276
|
+
let html = '';
|
|
1277
|
+
if (type === 'transfer') {
|
|
1278
|
+
html = \`
|
|
1279
|
+
<h3>Transfer T\${data._index + 1} Details</h3>
|
|
1280
|
+
<div class="detail-row"><span class="detail-label">ID:</span><span class="detail-value">\${data.id}</span></div>
|
|
1281
|
+
<div class="detail-row"><span class="detail-label">Route:</span><span class="detail-value">\${data.origin} → \${data.destination}</span></div>
|
|
1282
|
+
<div class="detail-row"><span class="detail-label">Amount:</span><span class="detail-value">\${formatAmount(data.amount)}</span></div>
|
|
1283
|
+
<div class="detail-row"><span class="detail-label">Start:</span><span class="detail-value">\${new Date(data.startTime).toISOString()}</span></div>
|
|
1284
|
+
<div class="detail-row"><span class="detail-label">End:</span><span class="detail-value">\${data.endTime ? new Date(data.endTime).toISOString() : 'N/A'}</span></div>
|
|
1285
|
+
<div class="detail-row"><span class="detail-label">Latency:</span><span class="detail-value">\${data.latency ? data.latency + 'ms' : 'N/A'}</span></div>
|
|
1286
|
+
<div class="detail-row"><span class="detail-label">Status:</span><span class="detail-value">\${data.status}</span></div>
|
|
1287
|
+
\`;
|
|
1288
|
+
} else {
|
|
1289
|
+
html = \`
|
|
1290
|
+
<h3>Rebalance R\${data._index + 1} Details</h3>
|
|
1291
|
+
<div class="detail-row"><span class="detail-label">ID:</span><span class="detail-value">\${data.id}</span></div>
|
|
1292
|
+
<div class="detail-row"><span class="detail-label">Route:</span><span class="detail-value">\${data.origin} → \${data.destination}</span></div>
|
|
1293
|
+
<div class="detail-row"><span class="detail-label">Amount:</span><span class="detail-value">\${formatAmount(data.amount)}</span></div>
|
|
1294
|
+
<div class="detail-row"><span class="detail-label">Start:</span><span class="detail-value">\${new Date(data.startTime).toISOString()}</span></div>
|
|
1295
|
+
<div class="detail-row"><span class="detail-label">End:</span><span class="detail-value">\${data.endTime ? new Date(data.endTime).toISOString() : 'N/A'}</span></div>
|
|
1296
|
+
<div class="detail-row"><span class="detail-label">Latency:</span><span class="detail-value">\${data.latency ? data.latency + 'ms' : 'N/A'}</span></div>
|
|
1297
|
+
<div class="detail-row"><span class="detail-label">Gas Cost:</span><span class="detail-value">\${formatAmount(data.gasCost)}</span></div>
|
|
1298
|
+
<div class="detail-row"><span class="detail-label">Status:</span><span class="detail-value">\${data.status}</span></div>
|
|
1299
|
+
\`;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
panel.innerHTML = html;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function formatAmount(amount) {
|
|
1306
|
+
const val = BigInt(amount);
|
|
1307
|
+
const eth = Number(val) / 1e18;
|
|
1308
|
+
if (eth >= 1) return eth.toFixed(2) + ' tokens';
|
|
1309
|
+
if (eth >= 0.001) return (eth * 1000).toFixed(2) + ' mTokens';
|
|
1310
|
+
return val.toString() + ' wei';
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function formatBalanceShort(balance) {
|
|
1314
|
+
const eth = Number(balance) / 1e18;
|
|
1315
|
+
if (eth >= 1000) return (eth / 1000).toFixed(1) + 'k';
|
|
1316
|
+
if (eth >= 1) return eth.toFixed(0);
|
|
1317
|
+
return eth.toFixed(2);
|
|
1318
|
+
}
|
|
1319
|
+
`;
|
|
1320
|
+
}
|
|
1321
|
+
//# sourceMappingURL=HtmlTimelineGenerator.js.map
|