terraformgraph 1.0.1__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,1134 @@
1
+ """
2
+ SVG/HTML Renderer
3
+
4
+ Generates interactive HTML diagrams with:
5
+ - Drag-and-drop for repositioning services
6
+ - Connections that follow moved elements
7
+ - Export to PNG/JPG
8
+ """
9
+
10
+ import html
11
+ import re
12
+ from typing import Dict, List, Optional
13
+
14
+ from .aggregator import AggregatedResult, LogicalConnection, LogicalService
15
+ from .icons import IconMapper
16
+ from .layout import LayoutConfig, Position, ServiceGroup
17
+
18
+
19
+ class SVGRenderer:
20
+ """Renders infrastructure diagrams as SVG."""
21
+
22
+ def __init__(self, icon_mapper: IconMapper, config: Optional[LayoutConfig] = None):
23
+ self.icon_mapper = icon_mapper
24
+ self.config = config or LayoutConfig()
25
+
26
+ def render_svg(
27
+ self,
28
+ services: List[LogicalService],
29
+ positions: Dict[str, Position],
30
+ connections: List[LogicalConnection],
31
+ groups: List[ServiceGroup]
32
+ ) -> str:
33
+ """Generate SVG content for the diagram."""
34
+ svg_parts = []
35
+
36
+ # SVG header with ID for export
37
+ svg_parts.append(f'''<svg id="diagram-svg" xmlns="http://www.w3.org/2000/svg"
38
+ xmlns:xlink="http://www.w3.org/1999/xlink"
39
+ viewBox="0 0 {self.config.canvas_width} {self.config.canvas_height}"
40
+ width="{self.config.canvas_width}" height="{self.config.canvas_height}">''')
41
+
42
+ # Defs for arrows and filters
43
+ svg_parts.append(self._render_defs())
44
+
45
+ # Background
46
+ svg_parts.append('''<rect width="100%" height="100%" fill="#f8f9fa"/>''')
47
+
48
+ # Render groups (AWS Cloud, VPC)
49
+ for group in groups:
50
+ svg_parts.append(self._render_group(group))
51
+
52
+ # Connections container (will be updated dynamically)
53
+ svg_parts.append('<g id="connections-layer">')
54
+ for conn in connections:
55
+ if conn.source_id in positions and conn.target_id in positions:
56
+ svg_parts.append(self._render_connection(
57
+ positions[conn.source_id],
58
+ positions[conn.target_id],
59
+ conn
60
+ ))
61
+ svg_parts.append('</g>')
62
+
63
+ # Services layer
64
+ svg_parts.append('<g id="services-layer">')
65
+ for service in services:
66
+ if service.id in positions:
67
+ svg_parts.append(self._render_service(service, positions[service.id]))
68
+ svg_parts.append('</g>')
69
+
70
+ svg_parts.append('</svg>')
71
+
72
+ return '\n'.join(svg_parts)
73
+
74
+ def _render_defs(self) -> str:
75
+ """Render SVG definitions (markers, filters)."""
76
+ return '''
77
+ <defs>
78
+ <marker id="arrowhead" markerWidth="10" markerHeight="7"
79
+ refX="9" refY="3.5" orient="auto">
80
+ <polygon points="0 0, 10 3.5, 0 7" fill="#999"/>
81
+ </marker>
82
+ <marker id="arrowhead-data" markerWidth="10" markerHeight="7"
83
+ refX="9" refY="3.5" orient="auto">
84
+ <polygon points="0 0, 10 3.5, 0 7" fill="#3B48CC"/>
85
+ </marker>
86
+ <marker id="arrowhead-trigger" markerWidth="10" markerHeight="7"
87
+ refX="9" refY="3.5" orient="auto">
88
+ <polygon points="0 0, 10 3.5, 0 7" fill="#E7157B"/>
89
+ </marker>
90
+ <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
91
+ <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
92
+ </filter>
93
+ </defs>
94
+ '''
95
+
96
+ def _render_group(self, group: ServiceGroup) -> str:
97
+ """Render a group container (AWS Cloud, VPC)."""
98
+ if not group.position:
99
+ return ''
100
+
101
+ pos = group.position
102
+
103
+ colors = {
104
+ 'aws_cloud': ('#232f3e', '#ffffff', '#232f3e'),
105
+ 'vpc': ('#8c4fff', '#faf8ff', '#8c4fff'),
106
+ }
107
+
108
+ border_color, bg_color, text_color = colors.get(group.group_type, ('#666', '#fff', '#666'))
109
+
110
+ return f'''
111
+ <g class="group group-{group.group_type}" data-group-type="{group.group_type}">
112
+ <rect class="group-bg" x="{pos.x}" y="{pos.y}" width="{pos.width}" height="{pos.height}"
113
+ fill="{bg_color}" stroke="{border_color}" stroke-width="2"
114
+ stroke-dasharray="8,4" rx="12" ry="12"
115
+ data-min-x="{pos.x}" data-min-y="{pos.y}"
116
+ data-max-x="{pos.x + pos.width}" data-max-y="{pos.y + pos.height}"/>
117
+ <text x="{pos.x + 15}" y="{pos.y + 22}"
118
+ font-family="Arial, sans-serif" font-size="14" font-weight="bold"
119
+ fill="{text_color}">{html.escape(group.name)}</text>
120
+ </g>
121
+ '''
122
+
123
+ def _render_service(self, service: LogicalService, pos: Position) -> str:
124
+ """Render a draggable logical service with its icon."""
125
+ icon_svg = self.icon_mapper.get_icon_svg(service.icon_resource_type, 48)
126
+ color = self.icon_mapper.get_category_color(service.icon_resource_type)
127
+
128
+ # Count badge
129
+ count_badge = ''
130
+ if service.count > 1:
131
+ count_badge = f'''
132
+ <circle class="count-badge" cx="{pos.width - 8}" cy="8" r="12"
133
+ fill="{color}" stroke="white" stroke-width="2"/>
134
+ <text class="count-text" x="{pos.width - 8}" y="12"
135
+ font-family="Arial, sans-serif" font-size="11" fill="white"
136
+ text-anchor="middle" font-weight="bold">{service.count}</text>
137
+ '''
138
+
139
+ resource_count = len(service.resources)
140
+ tooltip = f"{service.name} ({resource_count} resources)"
141
+
142
+ # Determine if this is a VPC service
143
+ is_vpc_service = 'true' if service.is_vpc_resource else 'false'
144
+
145
+ if icon_svg:
146
+ icon_content = self._extract_svg_content(icon_svg)
147
+
148
+ svg = f'''
149
+ <g class="service draggable" data-service-id="{html.escape(service.id)}"
150
+ data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}"
151
+ transform="translate({pos.x}, {pos.y})" style="cursor: grab;">
152
+ <rect class="service-bg" x="-8" y="-8"
153
+ width="{pos.width + 16}" height="{pos.height + 36}"
154
+ fill="white" stroke="#e0e0e0" stroke-width="1" rx="8" ry="8"
155
+ filter="url(#shadow)"/>
156
+ <svg class="service-icon" width="{pos.width}" height="{pos.height}" viewBox="0 0 64 64">
157
+ {icon_content}
158
+ </svg>
159
+ <text class="service-label" x="{pos.width/2}" y="{pos.height + 16}"
160
+ font-family="Arial, sans-serif" font-size="12" fill="#333"
161
+ text-anchor="middle" font-weight="500">
162
+ {html.escape(service.name)}
163
+ </text>
164
+ {count_badge}
165
+ </g>
166
+ '''
167
+ else:
168
+ svg = f'''
169
+ <g class="service draggable" data-service-id="{html.escape(service.id)}"
170
+ data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}"
171
+ transform="translate({pos.x}, {pos.y})" style="cursor: grab;">
172
+ <rect class="service-bg" x="-8" y="-8"
173
+ width="{pos.width + 16}" height="{pos.height + 36}"
174
+ fill="white" stroke="#e0e0e0" stroke-width="1" rx="8" ry="8"
175
+ filter="url(#shadow)"/>
176
+ <rect x="0" y="0" width="{pos.width}" height="{pos.height}"
177
+ fill="{color}" rx="8" ry="8"/>
178
+ <text x="{pos.width/2}" y="{pos.height/2 + 5}"
179
+ font-family="Arial, sans-serif" font-size="11" fill="white"
180
+ text-anchor="middle">{html.escape(service.service_type[:8])}</text>
181
+ <text class="service-label" x="{pos.width/2}" y="{pos.height + 16}"
182
+ font-family="Arial, sans-serif" font-size="12" fill="#333"
183
+ text-anchor="middle" font-weight="500">
184
+ {html.escape(service.name)}
185
+ </text>
186
+ {count_badge}
187
+ </g>
188
+ '''
189
+
190
+ return svg
191
+
192
+ def _extract_svg_content(self, svg_string: str) -> str:
193
+ """Extract the inner content of an SVG, removing outer tags."""
194
+ svg_string = re.sub(r'<\?xml[^?]*\?>\s*', '', svg_string)
195
+ match = re.search(r'<svg[^>]*>(.*)</svg>', svg_string, re.DOTALL)
196
+ if match:
197
+ return match.group(1)
198
+ return ''
199
+
200
+ def _render_connection(
201
+ self,
202
+ source_pos: Position,
203
+ target_pos: Position,
204
+ connection: LogicalConnection
205
+ ) -> str:
206
+ """Render a connection line between services."""
207
+ styles = {
208
+ 'data_flow': ('#3B48CC', '', 'url(#arrowhead-data)'),
209
+ 'trigger': ('#E7157B', '', 'url(#arrowhead-trigger)'),
210
+ 'encrypt': ('#6c757d', '4,4', 'url(#arrowhead)'),
211
+ 'default': ('#999999', '', 'url(#arrowhead)'),
212
+ }
213
+
214
+ stroke_color, stroke_dash, marker = styles.get(connection.connection_type, styles['default'])
215
+ dash_attr = f'stroke-dasharray="{stroke_dash}"' if stroke_dash else ''
216
+
217
+ # Calculate initial path
218
+ half_size = self.config.icon_size / 2
219
+ sx = source_pos.x + half_size
220
+ sy = source_pos.y + half_size
221
+ tx = target_pos.x + half_size
222
+ ty = target_pos.y + half_size
223
+
224
+ # Adjust to connect from edges
225
+ if abs(ty - sy) > abs(tx - sx):
226
+ # Mostly vertical
227
+ if ty > sy:
228
+ sy = source_pos.y + self.config.icon_size + 8
229
+ ty = target_pos.y - 8
230
+ else:
231
+ sy = source_pos.y - 8
232
+ ty = target_pos.y + self.config.icon_size + 8
233
+ else:
234
+ # Mostly horizontal
235
+ if tx > sx:
236
+ sx = source_pos.x + self.config.icon_size + 8
237
+ tx = target_pos.x - 8
238
+ else:
239
+ sx = source_pos.x - 8
240
+ tx = target_pos.x + self.config.icon_size + 8
241
+
242
+ # Simple quadratic curve path (better for export)
243
+ mid_x = (sx + tx) / 2
244
+ mid_y = (sy + ty) / 2
245
+ path = f"M {sx} {sy} Q {mid_x} {sy}, {mid_x} {mid_y} T {tx} {ty}"
246
+
247
+ label = connection.label or ''
248
+ return f'''
249
+ <g class="connection" data-source="{html.escape(connection.source_id)}"
250
+ data-target="{html.escape(connection.target_id)}"
251
+ data-conn-type="{connection.connection_type}"
252
+ data-label="{html.escape(label)}">
253
+ <path class="connection-hitarea" d="{path}"/>
254
+ <path class="connection-path" d="{path}" fill="none" stroke="{stroke_color}"
255
+ stroke-width="1.5" {dash_attr} marker-end="{marker}" opacity="0.7"/>
256
+ </g>
257
+ '''
258
+
259
+
260
+ class HTMLRenderer:
261
+ """Wraps SVG in interactive HTML with drag-and-drop and export."""
262
+
263
+ HTML_TEMPLATE = '''<!DOCTYPE html>
264
+ <html lang="en">
265
+ <head>
266
+ <meta charset="UTF-8">
267
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
268
+ <title>AWS Infrastructure Diagram</title>
269
+ <style>
270
+ * {{ box-sizing: border-box; }}
271
+ body {{
272
+ margin: 0;
273
+ padding: 20px;
274
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
275
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
276
+ min-height: 100vh;
277
+ }}
278
+ .container {{
279
+ max-width: 1500px;
280
+ margin: 0 auto;
281
+ }}
282
+ .header {{
283
+ display: flex;
284
+ justify-content: space-between;
285
+ align-items: center;
286
+ margin-bottom: 20px;
287
+ padding: 20px 25px;
288
+ background: white;
289
+ border-radius: 12px;
290
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
291
+ }}
292
+ .header h1 {{
293
+ margin: 0;
294
+ font-size: 24px;
295
+ color: #232f3e;
296
+ }}
297
+ .header .subtitle {{
298
+ margin: 4px 0 0 0;
299
+ font-size: 14px;
300
+ color: #666;
301
+ }}
302
+ .header-right {{
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 20px;
306
+ }}
307
+ .stats {{
308
+ display: flex;
309
+ gap: 30px;
310
+ }}
311
+ .stat {{
312
+ text-align: center;
313
+ }}
314
+ .stat-value {{
315
+ font-size: 28px;
316
+ font-weight: bold;
317
+ color: #8c4fff;
318
+ }}
319
+ .stat-label {{
320
+ font-size: 12px;
321
+ color: #666;
322
+ text-transform: uppercase;
323
+ }}
324
+ .export-buttons {{
325
+ display: flex;
326
+ gap: 10px;
327
+ }}
328
+ .export-btn {{
329
+ padding: 10px 16px;
330
+ border: none;
331
+ border-radius: 8px;
332
+ font-size: 13px;
333
+ font-weight: 500;
334
+ cursor: pointer;
335
+ transition: all 0.2s;
336
+ }}
337
+ .export-btn-primary {{
338
+ background: #8c4fff;
339
+ color: white;
340
+ }}
341
+ .export-btn-primary:hover {{
342
+ background: #7a3de8;
343
+ }}
344
+ .export-btn-secondary {{
345
+ background: #e9ecef;
346
+ color: #333;
347
+ }}
348
+ .export-btn-secondary:hover {{
349
+ background: #dee2e6;
350
+ }}
351
+ .diagram-container {{
352
+ background: white;
353
+ border-radius: 12px;
354
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
355
+ overflow: hidden;
356
+ position: relative;
357
+ }}
358
+ .toolbar {{
359
+ display: flex;
360
+ justify-content: space-between;
361
+ align-items: center;
362
+ padding: 12px 20px;
363
+ background: #f8f9fa;
364
+ border-bottom: 1px solid #e9ecef;
365
+ }}
366
+ .toolbar-info {{
367
+ font-size: 13px;
368
+ color: #666;
369
+ }}
370
+ .toolbar-actions {{
371
+ display: flex;
372
+ gap: 10px;
373
+ }}
374
+ .toolbar-btn {{
375
+ padding: 6px 12px;
376
+ border: 1px solid #ddd;
377
+ background: white;
378
+ border-radius: 6px;
379
+ font-size: 12px;
380
+ cursor: pointer;
381
+ transition: all 0.2s;
382
+ }}
383
+ .toolbar-btn:hover {{
384
+ background: #f0f0f0;
385
+ border-color: #ccc;
386
+ }}
387
+ .diagram-wrapper {{
388
+ padding: 20px;
389
+ overflow: auto;
390
+ max-height: 70vh;
391
+ }}
392
+ .diagram-wrapper svg {{
393
+ display: block;
394
+ margin: 0 auto;
395
+ }}
396
+ .service.dragging {{
397
+ opacity: 0.8;
398
+ cursor: grabbing !important;
399
+ }}
400
+ .service:hover .service-bg {{
401
+ stroke: #8c4fff;
402
+ stroke-width: 2;
403
+ }}
404
+ /* Highlighting states */
405
+ .service.highlighted .service-bg {{
406
+ stroke: #8c4fff;
407
+ stroke-width: 3;
408
+ filter: url(#shadow) drop-shadow(0 0 8px rgba(140, 79, 255, 0.5));
409
+ }}
410
+ .service.dimmed {{
411
+ opacity: 0.3;
412
+ }}
413
+ .connection.highlighted .connection-path {{
414
+ stroke-width: 3 !important;
415
+ opacity: 1 !important;
416
+ }}
417
+ .connection.dimmed {{
418
+ opacity: 0.1 !important;
419
+ }}
420
+ .connection {{
421
+ cursor: pointer;
422
+ }}
423
+ .connection:hover .connection-path {{
424
+ stroke-width: 3;
425
+ opacity: 1;
426
+ }}
427
+ .connection-hitarea {{
428
+ stroke: transparent;
429
+ stroke-width: 15;
430
+ fill: none;
431
+ cursor: pointer;
432
+ }}
433
+ .legend {{
434
+ margin-top: 20px;
435
+ padding: 20px 25px;
436
+ background: white;
437
+ border-radius: 12px;
438
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
439
+ }}
440
+ .legend h3 {{
441
+ margin: 0 0 15px 0;
442
+ font-size: 16px;
443
+ color: #232f3e;
444
+ }}
445
+ .legend-grid {{
446
+ display: grid;
447
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
448
+ gap: 20px;
449
+ }}
450
+ .legend-section h4 {{
451
+ margin: 0 0 10px 0;
452
+ font-size: 13px;
453
+ color: #666;
454
+ text-transform: uppercase;
455
+ }}
456
+ .legend-items {{
457
+ display: flex;
458
+ flex-direction: column;
459
+ gap: 8px;
460
+ }}
461
+ .legend-item {{
462
+ display: flex;
463
+ align-items: center;
464
+ gap: 10px;
465
+ font-size: 13px;
466
+ }}
467
+ .legend-line {{
468
+ width: 30px;
469
+ height: 3px;
470
+ border-radius: 2px;
471
+ }}
472
+ .tooltip {{
473
+ position: fixed;
474
+ padding: 10px 14px;
475
+ background: #232f3e;
476
+ color: white;
477
+ border-radius: 6px;
478
+ font-size: 13px;
479
+ pointer-events: none;
480
+ z-index: 1000;
481
+ display: none;
482
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
483
+ }}
484
+ .export-modal {{
485
+ display: none;
486
+ position: fixed;
487
+ top: 0;
488
+ left: 0;
489
+ width: 100%;
490
+ height: 100%;
491
+ background: rgba(0,0,0,0.5);
492
+ z-index: 2000;
493
+ justify-content: center;
494
+ align-items: center;
495
+ }}
496
+ .export-modal.active {{
497
+ display: flex;
498
+ }}
499
+ .export-modal-content {{
500
+ background: white;
501
+ padding: 30px;
502
+ border-radius: 12px;
503
+ text-align: center;
504
+ max-width: 400px;
505
+ }}
506
+ .export-modal h3 {{
507
+ margin: 0 0 20px 0;
508
+ }}
509
+ .export-preview {{
510
+ max-width: 100%;
511
+ border: 1px solid #ddd;
512
+ border-radius: 8px;
513
+ margin-bottom: 20px;
514
+ }}
515
+ .export-modal-actions {{
516
+ display: flex;
517
+ gap: 10px;
518
+ justify-content: center;
519
+ }}
520
+ .highlight-info {{
521
+ position: fixed;
522
+ bottom: 20px;
523
+ right: 20px;
524
+ padding: 15px 20px;
525
+ background: #232f3e;
526
+ color: white;
527
+ border-radius: 10px;
528
+ font-size: 14px;
529
+ line-height: 1.6;
530
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
531
+ z-index: 1000;
532
+ display: none;
533
+ max-width: 280px;
534
+ }}
535
+ .highlight-info strong {{
536
+ color: #8c4fff;
537
+ }}
538
+ .highlight-info small {{
539
+ color: #999;
540
+ display: block;
541
+ margin-top: 8px;
542
+ font-size: 11px;
543
+ }}
544
+ </style>
545
+ </head>
546
+ <body>
547
+ <div class="container">
548
+ <div class="header">
549
+ <div>
550
+ <h1>AWS Infrastructure Diagram</h1>
551
+ <p class="subtitle">Environment: {environment} | Drag icons to reposition</p>
552
+ </div>
553
+ <div class="header-right">
554
+ <div class="stats">
555
+ <div class="stat">
556
+ <div class="stat-value">{service_count}</div>
557
+ <div class="stat-label">Services</div>
558
+ </div>
559
+ <div class="stat">
560
+ <div class="stat-value">{resource_count}</div>
561
+ <div class="stat-label">Resources</div>
562
+ </div>
563
+ <div class="stat">
564
+ <div class="stat-value">{connection_count}</div>
565
+ <div class="stat-label">Connections</div>
566
+ </div>
567
+ </div>
568
+ <div class="export-buttons">
569
+ <button class="export-btn export-btn-secondary" onclick="exportAs('png')">Export PNG</button>
570
+ <button class="export-btn export-btn-primary" onclick="exportAs('jpg')">Export JPG</button>
571
+ </div>
572
+ </div>
573
+ </div>
574
+ <div class="diagram-container">
575
+ <div class="toolbar">
576
+ <div class="toolbar-info">Click and drag services to reposition. Connections update automatically.</div>
577
+ <div class="toolbar-actions">
578
+ <button class="toolbar-btn" onclick="resetPositions()">Reset Layout</button>
579
+ <button class="toolbar-btn" onclick="savePositions()">Save Layout</button>
580
+ <button class="toolbar-btn" onclick="loadPositions()">Load Layout</button>
581
+ </div>
582
+ </div>
583
+ <div class="diagram-wrapper" id="diagram-wrapper">
584
+ {svg_content}
585
+ </div>
586
+ </div>
587
+ <div class="legend">
588
+ <h3>Legend</h3>
589
+ <div class="legend-grid">
590
+ <div class="legend-section">
591
+ <h4>Connection Types</h4>
592
+ <div class="legend-items">
593
+ <div class="legend-item">
594
+ <div class="legend-line" style="background: #3B48CC;"></div>
595
+ <span>Data Flow</span>
596
+ </div>
597
+ <div class="legend-item">
598
+ <div class="legend-line" style="background: #E7157B;"></div>
599
+ <span>Event Trigger</span>
600
+ </div>
601
+ <div class="legend-item">
602
+ <div class="legend-line" style="background: #6c757d;"></div>
603
+ <span>Encryption</span>
604
+ </div>
605
+ <div class="legend-item">
606
+ <div class="legend-line" style="background: #999;"></div>
607
+ <span>Reference</span>
608
+ </div>
609
+ </div>
610
+ </div>
611
+ <div class="legend-section">
612
+ <h4>Instructions</h4>
613
+ <div class="legend-items">
614
+ <div class="legend-item">Drag icons to reposition</div>
615
+ <div class="legend-item">VPC services stay within VPC bounds</div>
616
+ <div class="legend-item">Use Save/Load to persist layout</div>
617
+ <div class="legend-item">Export as PNG or JPG for sharing</div>
618
+ </div>
619
+ </div>
620
+ </div>
621
+ </div>
622
+ </div>
623
+ <div class="tooltip" id="tooltip"></div>
624
+ <div class="highlight-info" id="highlight-info"></div>
625
+ <div class="export-modal" id="export-modal">
626
+ <div class="export-modal-content">
627
+ <h3>Export Diagram</h3>
628
+ <canvas id="export-canvas" style="display:none;"></canvas>
629
+ <img id="export-preview" class="export-preview" alt="Preview"/>
630
+ <div class="export-modal-actions">
631
+ <button class="export-btn export-btn-secondary" onclick="closeExportModal()">Cancel</button>
632
+ <a id="export-download" class="export-btn export-btn-primary" download="diagram.png">Download</a>
633
+ </div>
634
+ </div>
635
+ </div>
636
+
637
+ <script>
638
+ // Service positions storage
639
+ const servicePositions = {{}};
640
+ const iconSize = {icon_size};
641
+ let originalPositions = {{}};
642
+
643
+ // Initialize
644
+ document.addEventListener('DOMContentLoaded', () => {{
645
+ initDragAndDrop();
646
+ initTooltips();
647
+ initHighlighting();
648
+ updateAllConnections();
649
+ saveOriginalPositions();
650
+ }});
651
+
652
+ function saveOriginalPositions() {{
653
+ document.querySelectorAll('.service').forEach(el => {{
654
+ const id = el.dataset.serviceId;
655
+ const transform = el.getAttribute('transform');
656
+ const match = transform.match(/translate\\(([^,]+),\\s*([^)]+)\\)/);
657
+ if (match) {{
658
+ originalPositions[id] = {{ x: parseFloat(match[1]), y: parseFloat(match[2]) }};
659
+ servicePositions[id] = {{ ...originalPositions[id] }};
660
+ }}
661
+ }});
662
+ }}
663
+
664
+ function initDragAndDrop() {{
665
+ const svg = document.getElementById('diagram-svg');
666
+ let dragging = null;
667
+ let offset = {{ x: 0, y: 0 }};
668
+
669
+ document.querySelectorAll('.service.draggable').forEach(el => {{
670
+ el.addEventListener('mousedown', startDrag);
671
+ }});
672
+
673
+ svg.addEventListener('mousemove', drag);
674
+ svg.addEventListener('mouseup', endDrag);
675
+ svg.addEventListener('mouseleave', endDrag);
676
+
677
+ function startDrag(e) {{
678
+ e.preventDefault();
679
+ dragging = e.currentTarget;
680
+ dragging.classList.add('dragging');
681
+ dragging.style.cursor = 'grabbing';
682
+
683
+ const pt = svg.createSVGPoint();
684
+ pt.x = e.clientX;
685
+ pt.y = e.clientY;
686
+ const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
687
+
688
+ const id = dragging.dataset.serviceId;
689
+ const pos = servicePositions[id] || {{ x: 0, y: 0 }};
690
+ offset.x = svgP.x - pos.x;
691
+ offset.y = svgP.y - pos.y;
692
+
693
+ // Hide tooltip while dragging
694
+ document.getElementById('tooltip').style.display = 'none';
695
+ }}
696
+
697
+ function drag(e) {{
698
+ if (!dragging) return;
699
+
700
+ const pt = svg.createSVGPoint();
701
+ pt.x = e.clientX;
702
+ pt.y = e.clientY;
703
+ const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
704
+
705
+ let newX = svgP.x - offset.x;
706
+ let newY = svgP.y - offset.y;
707
+
708
+ // Constrain to VPC if it's a VPC service
709
+ if (dragging.dataset.isVpc === 'true') {{
710
+ const vpcGroup = document.querySelector('.group-vpc .group-bg');
711
+ if (vpcGroup) {{
712
+ const minX = parseFloat(vpcGroup.dataset.minX) + 20;
713
+ const minY = parseFloat(vpcGroup.dataset.minY) + 40;
714
+ const maxX = parseFloat(vpcGroup.dataset.maxX) - iconSize - 20;
715
+ const maxY = parseFloat(vpcGroup.dataset.maxY) - iconSize - 40;
716
+
717
+ newX = Math.max(minX, Math.min(maxX, newX));
718
+ newY = Math.max(minY, Math.min(maxY, newY));
719
+ }}
720
+ }} else {{
721
+ // Constrain to AWS Cloud bounds
722
+ const cloudGroup = document.querySelector('.group-aws_cloud .group-bg');
723
+ if (cloudGroup) {{
724
+ const minX = parseFloat(cloudGroup.dataset.minX) + 20;
725
+ const minY = parseFloat(cloudGroup.dataset.minY) + 40;
726
+ const maxX = parseFloat(cloudGroup.dataset.maxX) - iconSize - 20;
727
+ const maxY = parseFloat(cloudGroup.dataset.maxY) - iconSize - 40;
728
+
729
+ newX = Math.max(minX, Math.min(maxX, newX));
730
+ newY = Math.max(minY, Math.min(maxY, newY));
731
+ }}
732
+ }}
733
+
734
+ const id = dragging.dataset.serviceId;
735
+ servicePositions[id] = {{ x: newX, y: newY }};
736
+
737
+ dragging.setAttribute('transform', `translate(${{newX}}, ${{newY}})`);
738
+ updateConnectionsFor(id);
739
+ }}
740
+
741
+ function endDrag() {{
742
+ if (dragging) {{
743
+ dragging.classList.remove('dragging');
744
+ dragging.style.cursor = 'grab';
745
+ dragging = null;
746
+ }}
747
+ }}
748
+ }}
749
+
750
+ function updateConnectionsFor(serviceId) {{
751
+ document.querySelectorAll('.connection').forEach(conn => {{
752
+ if (conn.dataset.source === serviceId || conn.dataset.target === serviceId) {{
753
+ updateConnection(conn);
754
+ }}
755
+ }});
756
+ }}
757
+
758
+ function updateAllConnections() {{
759
+ document.querySelectorAll('.connection').forEach(updateConnection);
760
+ }}
761
+
762
+ function updateConnection(connEl) {{
763
+ const sourceId = connEl.dataset.source;
764
+ const targetId = connEl.dataset.target;
765
+
766
+ const sourcePos = servicePositions[sourceId];
767
+ const targetPos = servicePositions[targetId];
768
+
769
+ if (!sourcePos || !targetPos) return;
770
+
771
+ // Calculate center points
772
+ const halfSize = iconSize / 2;
773
+ let sx = sourcePos.x + halfSize;
774
+ let sy = sourcePos.y + halfSize;
775
+ let tx = targetPos.x + halfSize;
776
+ let ty = targetPos.y + halfSize;
777
+
778
+ // Adjust to connect from edges
779
+ if (Math.abs(ty - sy) > Math.abs(tx - sx)) {{
780
+ // Mostly vertical
781
+ if (ty > sy) {{
782
+ sy = sourcePos.y + iconSize + 8;
783
+ ty = targetPos.y - 8;
784
+ }} else {{
785
+ sy = sourcePos.y - 8;
786
+ ty = targetPos.y + iconSize + 8;
787
+ }}
788
+ }} else {{
789
+ // Mostly horizontal
790
+ if (tx > sx) {{
791
+ sx = sourcePos.x + iconSize + 8;
792
+ tx = targetPos.x - 8;
793
+ }} else {{
794
+ sx = sourcePos.x - 8;
795
+ tx = targetPos.x + iconSize + 8;
796
+ }}
797
+ }}
798
+
799
+ // Quadratic curve path (matches server-side rendering)
800
+ const midX = (sx + tx) / 2;
801
+ const midY = (sy + ty) / 2;
802
+ const path = `M ${{sx}} ${{sy}} Q ${{midX}} ${{sy}}, ${{midX}} ${{midY}} T ${{tx}} ${{ty}}`;
803
+
804
+ const pathEl = connEl.querySelector('.connection-path');
805
+ const hitareaEl = connEl.querySelector('.connection-hitarea');
806
+ if (pathEl) {{
807
+ pathEl.setAttribute('d', path);
808
+ }}
809
+ if (hitareaEl) {{
810
+ hitareaEl.setAttribute('d', path);
811
+ }}
812
+ }}
813
+
814
+ // ============ HIGHLIGHTING SYSTEM ============
815
+ let currentHighlight = null;
816
+
817
+ function initHighlighting() {{
818
+ // Click on service to highlight connections
819
+ document.querySelectorAll('.service').forEach(el => {{
820
+ el.addEventListener('click', (e) => {{
821
+ // Don't highlight if dragging
822
+ if (el.classList.contains('dragging')) return;
823
+ e.stopPropagation();
824
+
825
+ const serviceId = el.dataset.serviceId;
826
+
827
+ // Toggle highlight
828
+ if (currentHighlight === serviceId) {{
829
+ clearHighlights();
830
+ }} else {{
831
+ highlightService(serviceId);
832
+ }}
833
+ }});
834
+ }});
835
+
836
+ // Click on connection to highlight
837
+ document.querySelectorAll('.connection').forEach(el => {{
838
+ el.addEventListener('click', (e) => {{
839
+ e.stopPropagation();
840
+
841
+ const sourceId = el.dataset.source;
842
+ const targetId = el.dataset.target;
843
+ const connKey = `conn:${{sourceId}}->${{targetId}}`;
844
+
845
+ // Toggle highlight
846
+ if (currentHighlight === connKey) {{
847
+ clearHighlights();
848
+ }} else {{
849
+ highlightConnection(el, sourceId, targetId);
850
+ }}
851
+ }});
852
+ }});
853
+
854
+ // Click on background to clear highlights
855
+ document.getElementById('diagram-svg').addEventListener('click', (e) => {{
856
+ if (e.target.tagName === 'svg' || e.target.classList.contains('group-bg')) {{
857
+ clearHighlights();
858
+ }}
859
+ }});
860
+ }}
861
+
862
+ function highlightService(serviceId) {{
863
+ clearHighlights();
864
+ currentHighlight = serviceId;
865
+
866
+ // Find all connected services
867
+ const connectedServices = new Set([serviceId]);
868
+ const connectedConnections = [];
869
+
870
+ document.querySelectorAll('.connection').forEach(conn => {{
871
+ const source = conn.dataset.source;
872
+ const target = conn.dataset.target;
873
+
874
+ if (source === serviceId || target === serviceId) {{
875
+ connectedServices.add(source);
876
+ connectedServices.add(target);
877
+ connectedConnections.push(conn);
878
+ }}
879
+ }});
880
+
881
+ // Dim all services and connections
882
+ document.querySelectorAll('.service').forEach(el => {{
883
+ el.classList.add('dimmed');
884
+ }});
885
+ document.querySelectorAll('.connection').forEach(el => {{
886
+ el.classList.add('dimmed');
887
+ }});
888
+
889
+ // Highlight connected services
890
+ connectedServices.forEach(id => {{
891
+ const el = document.querySelector(`[data-service-id="${{id}}"]`);
892
+ if (el) {{
893
+ el.classList.remove('dimmed');
894
+ el.classList.add('highlighted');
895
+ }}
896
+ }});
897
+
898
+ // Highlight connected connections
899
+ connectedConnections.forEach(conn => {{
900
+ conn.classList.remove('dimmed');
901
+ conn.classList.add('highlighted');
902
+ }});
903
+
904
+ // Show info tooltip
905
+ showHighlightInfo(serviceId, connectedServices.size - 1, connectedConnections.length);
906
+ }}
907
+
908
+ function highlightConnection(connEl, sourceId, targetId) {{
909
+ clearHighlights();
910
+ currentHighlight = `conn:${{sourceId}}->${{targetId}}`;
911
+
912
+ // Dim all
913
+ document.querySelectorAll('.service').forEach(el => {{
914
+ el.classList.add('dimmed');
915
+ }});
916
+ document.querySelectorAll('.connection').forEach(el => {{
917
+ el.classList.add('dimmed');
918
+ }});
919
+
920
+ // Highlight the connection
921
+ connEl.classList.remove('dimmed');
922
+ connEl.classList.add('highlighted');
923
+
924
+ // Highlight source and target services
925
+ const sourceEl = document.querySelector(`[data-service-id="${{sourceId}}"]`);
926
+ const targetEl = document.querySelector(`[data-service-id="${{targetId}}"]`);
927
+
928
+ if (sourceEl) {{
929
+ sourceEl.classList.remove('dimmed');
930
+ sourceEl.classList.add('highlighted');
931
+ }}
932
+ if (targetEl) {{
933
+ targetEl.classList.remove('dimmed');
934
+ targetEl.classList.add('highlighted');
935
+ }}
936
+
937
+ // Show connection info
938
+ const label = connEl.dataset.label || connEl.dataset.connType;
939
+ const sourceName = sourceEl ? sourceEl.dataset.tooltip.split(' (')[0] : sourceId;
940
+ const targetName = targetEl ? targetEl.dataset.tooltip.split(' (')[0] : targetId;
941
+ showConnectionInfo(sourceName, targetName, label);
942
+ }}
943
+
944
+ function clearHighlights() {{
945
+ currentHighlight = null;
946
+
947
+ document.querySelectorAll('.service').forEach(el => {{
948
+ el.classList.remove('dimmed', 'highlighted');
949
+ }});
950
+ document.querySelectorAll('.connection').forEach(el => {{
951
+ el.classList.remove('dimmed', 'highlighted');
952
+ }});
953
+
954
+ hideHighlightInfo();
955
+ }}
956
+
957
+ function showHighlightInfo(serviceId, connectedCount, connectionCount) {{
958
+ const el = document.querySelector(`[data-service-id="${{serviceId}}"]`);
959
+ const name = el ? el.dataset.tooltip.split(' (')[0] : serviceId;
960
+
961
+ const infoEl = document.getElementById('highlight-info');
962
+ infoEl.innerHTML = `
963
+ <strong>${{name}}</strong><br>
964
+ Connected to ${{connectedCount}} service${{connectedCount !== 1 ? 's' : ''}}<br>
965
+ ${{connectionCount}} connection${{connectionCount !== 1 ? 's' : ''}}
966
+ <br><small>Click elsewhere to clear</small>
967
+ `;
968
+ infoEl.style.display = 'block';
969
+ }}
970
+
971
+ function showConnectionInfo(sourceName, targetName, label) {{
972
+ const infoEl = document.getElementById('highlight-info');
973
+ infoEl.innerHTML = `
974
+ <strong>${{sourceName}}</strong><br>
975
+ ↓ ${{label}}<br>
976
+ <strong>${{targetName}}</strong>
977
+ <br><small>Click elsewhere to clear</small>
978
+ `;
979
+ infoEl.style.display = 'block';
980
+ }}
981
+
982
+ function hideHighlightInfo() {{
983
+ document.getElementById('highlight-info').style.display = 'none';
984
+ }}
985
+
986
+ function initTooltips() {{
987
+ const tooltip = document.getElementById('tooltip');
988
+
989
+ document.querySelectorAll('.service').forEach(el => {{
990
+ el.addEventListener('mouseenter', (e) => {{
991
+ if (el.classList.contains('dragging')) return;
992
+ const data = el.dataset.tooltip;
993
+ if (data) {{
994
+ tooltip.textContent = data;
995
+ tooltip.style.display = 'block';
996
+ }}
997
+ }});
998
+ el.addEventListener('mousemove', (e) => {{
999
+ if (el.classList.contains('dragging')) return;
1000
+ tooltip.style.left = e.clientX + 15 + 'px';
1001
+ tooltip.style.top = e.clientY + 15 + 'px';
1002
+ }});
1003
+ el.addEventListener('mouseleave', () => {{
1004
+ tooltip.style.display = 'none';
1005
+ }});
1006
+ }});
1007
+ }}
1008
+
1009
+ function resetPositions() {{
1010
+ Object.keys(originalPositions).forEach(id => {{
1011
+ servicePositions[id] = {{ ...originalPositions[id] }};
1012
+ const el = document.querySelector(`[data-service-id="${{id}}"]`);
1013
+ if (el) {{
1014
+ el.setAttribute('transform', `translate(${{originalPositions[id].x}}, ${{originalPositions[id].y}})`);
1015
+ }}
1016
+ }});
1017
+ updateAllConnections();
1018
+ }}
1019
+
1020
+ function savePositions() {{
1021
+ const data = JSON.stringify(servicePositions);
1022
+ localStorage.setItem('diagramPositions', data);
1023
+ alert('Layout saved to browser storage!');
1024
+ }}
1025
+
1026
+ function loadPositions() {{
1027
+ const data = localStorage.getItem('diagramPositions');
1028
+ if (!data) {{
1029
+ alert('No saved layout found.');
1030
+ return;
1031
+ }}
1032
+
1033
+ const saved = JSON.parse(data);
1034
+ Object.keys(saved).forEach(id => {{
1035
+ if (servicePositions[id]) {{
1036
+ servicePositions[id] = saved[id];
1037
+ const el = document.querySelector(`[data-service-id="${{id}}"]`);
1038
+ if (el) {{
1039
+ el.setAttribute('transform', `translate(${{saved[id].x}}, ${{saved[id].y}})`);
1040
+ }}
1041
+ }}
1042
+ }});
1043
+ updateAllConnections();
1044
+ alert('Layout loaded!');
1045
+ }}
1046
+
1047
+ function exportAs(format) {{
1048
+ const svg = document.getElementById('diagram-svg');
1049
+ const canvas = document.getElementById('export-canvas');
1050
+ const ctx = canvas.getContext('2d');
1051
+
1052
+ // Set canvas size
1053
+ const svgRect = svg.getBoundingClientRect();
1054
+ const scale = 2; // Higher resolution
1055
+ canvas.width = svg.viewBox.baseVal.width * scale;
1056
+ canvas.height = svg.viewBox.baseVal.height * scale;
1057
+
1058
+ // Create image from SVG
1059
+ const svgData = new XMLSerializer().serializeToString(svg);
1060
+ const svgBlob = new Blob([svgData], {{ type: 'image/svg+xml;charset=utf-8' }});
1061
+ const url = URL.createObjectURL(svgBlob);
1062
+
1063
+ const img = new Image();
1064
+ img.onload = () => {{
1065
+ // White background for JPG
1066
+ if (format === 'jpg') {{
1067
+ ctx.fillStyle = 'white';
1068
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1069
+ }}
1070
+
1071
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
1072
+ URL.revokeObjectURL(url);
1073
+
1074
+ const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
1075
+ const quality = format === 'jpg' ? 0.95 : undefined;
1076
+ const dataUrl = canvas.toDataURL(mimeType, quality);
1077
+
1078
+ // Show modal with preview
1079
+ const preview = document.getElementById('export-preview');
1080
+ const download = document.getElementById('export-download');
1081
+
1082
+ preview.src = dataUrl;
1083
+ download.href = dataUrl;
1084
+ download.download = `aws-diagram.${{format}}`;
1085
+
1086
+ document.getElementById('export-modal').classList.add('active');
1087
+ }};
1088
+ img.src = url;
1089
+ }}
1090
+
1091
+ function closeExportModal() {{
1092
+ document.getElementById('export-modal').classList.remove('active');
1093
+ }}
1094
+
1095
+ // Close modal on background click
1096
+ document.getElementById('export-modal').addEventListener('click', (e) => {{
1097
+ if (e.target.id === 'export-modal') {{
1098
+ closeExportModal();
1099
+ }}
1100
+ }});
1101
+ </script>
1102
+ </body>
1103
+ </html>'''
1104
+
1105
+ def __init__(self, svg_renderer: SVGRenderer):
1106
+ self.svg_renderer = svg_renderer
1107
+
1108
+ def render_html(
1109
+ self,
1110
+ aggregated: AggregatedResult,
1111
+ positions: Dict[str, Position],
1112
+ groups: List[ServiceGroup],
1113
+ environment: str = 'dev'
1114
+ ) -> str:
1115
+ """Generate complete HTML page with interactive diagram."""
1116
+ svg_content = self.svg_renderer.render_svg(
1117
+ aggregated.services,
1118
+ positions,
1119
+ aggregated.connections,
1120
+ groups
1121
+ )
1122
+
1123
+ total_resources = sum(len(s.resources) for s in aggregated.services)
1124
+
1125
+ html_content = self.HTML_TEMPLATE.format(
1126
+ svg_content=svg_content,
1127
+ service_count=len(aggregated.services),
1128
+ resource_count=total_resources,
1129
+ connection_count=len(aggregated.connections),
1130
+ environment=environment,
1131
+ icon_size=self.svg_renderer.config.icon_size
1132
+ )
1133
+
1134
+ return html_content