terraformgraph 1.0.2__py3-none-any.whl → 1.0.4__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.
@@ -8,13 +8,17 @@ Generates interactive HTML diagrams with:
8
8
  """
9
9
 
10
10
  import html
11
+ import json
11
12
  import re
12
- from typing import Dict, List, Optional
13
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
13
14
 
14
- from .aggregator import AggregatedResult, LogicalConnection, LogicalService
15
+ from .aggregator import AggregatedResult, LogicalConnection, LogicalService, ResourceAggregator
15
16
  from .icons import IconMapper
16
17
  from .layout import LayoutConfig, Position, ServiceGroup
17
18
 
19
+ if TYPE_CHECKING:
20
+ from .aggregator import Subnet, VPCEndpoint, VPCStructure
21
+
18
22
 
19
23
  class SVGRenderer:
20
24
  """Renders infrastructure diagrams as SVG."""
@@ -28,52 +32,109 @@ class SVGRenderer:
28
32
  services: List[LogicalService],
29
33
  positions: Dict[str, Position],
30
34
  connections: List[LogicalConnection],
31
- groups: List[ServiceGroup]
35
+ groups: List[ServiceGroup],
36
+ vpc_structure: Optional["VPCStructure"] = None,
37
+ actual_height: Optional[int] = None,
32
38
  ) -> str:
33
39
  """Generate SVG content for the diagram."""
34
40
  svg_parts = []
35
41
 
36
- # SVG header with ID for export
37
- svg_parts.append(f'''<svg id="diagram-svg" xmlns="http://www.w3.org/2000/svg"
42
+ # Use actual height if provided (from layout engine), otherwise use config
43
+ canvas_height = actual_height if actual_height else self.config.canvas_height
44
+
45
+ # SVG header with responsive viewBox
46
+ # width="100%" allows SVG to scale to container, preserveAspectRatio maintains proportions
47
+ svg_parts.append(
48
+ f"""<svg id="diagram-svg" xmlns="http://www.w3.org/2000/svg"
38
49
  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}">''')
50
+ viewBox="0 0 {self.config.canvas_width} {canvas_height}"
51
+ width="100%" preserveAspectRatio="xMidYMin meet"
52
+ style="max-width: {self.config.canvas_width}px;">"""
53
+ )
41
54
 
42
55
  # Defs for arrows and filters
43
56
  svg_parts.append(self._render_defs())
44
57
 
45
58
  # Background
46
- svg_parts.append('''<rect width="100%" height="100%" fill="#f8f9fa"/>''')
59
+ svg_parts.append("""<rect width="100%" height="100%" fill="#f8f9fa"/>""")
47
60
 
48
- # Render groups (AWS Cloud, VPC)
61
+ # Render groups (AWS Cloud, VPC, AZ)
49
62
  for group in groups:
50
63
  svg_parts.append(self._render_group(group))
51
64
 
52
- # Connections container (will be updated dynamically)
65
+ # Build mapping from AWS subnet IDs to resource IDs for state-based lookups
66
+ aws_id_to_resource_id: Dict[str, str] = {}
67
+ if vpc_structure:
68
+ for az in vpc_structure.availability_zones:
69
+ for subnet in az.subnets:
70
+ if subnet.aws_id:
71
+ aws_id_to_resource_id[subnet.aws_id] = subnet.resource_id
72
+
73
+ # Render subnets layer (below connections)
74
+ if vpc_structure:
75
+ svg_parts.append('<g id="subnets-layer">')
76
+ for az in vpc_structure.availability_zones:
77
+ for subnet in az.subnets:
78
+ if subnet.resource_id in positions:
79
+ svg_parts.append(
80
+ self._render_subnet(
81
+ subnet.resource_id, positions[subnet.resource_id], subnet
82
+ )
83
+ )
84
+ svg_parts.append("</g>")
85
+
86
+ # Build service_type_map for connection rendering
87
+ service_type_map: Dict[str, str] = {}
88
+ for service in services:
89
+ service_type_map[service.id] = service.service_type
90
+
91
+ # Connections container - render individual connections
53
92
  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>')
93
+ for connection in connections:
94
+ source_pos = positions.get(connection.source_id)
95
+ target_pos = positions.get(connection.target_id)
96
+ if source_pos and target_pos:
97
+ svg_parts.append(
98
+ self._render_connection(
99
+ source_pos, target_pos, connection, service_type_map
100
+ )
101
+ )
102
+ svg_parts.append("</g>")
103
+
104
+ # Render VPC endpoints layer
105
+ if vpc_structure:
106
+ svg_parts.append('<g id="endpoints-layer">')
107
+ for endpoint in vpc_structure.endpoints:
108
+ if endpoint.resource_id in positions:
109
+ svg_parts.append(
110
+ self._render_vpc_endpoint(
111
+ endpoint.resource_id, positions[endpoint.resource_id], endpoint
112
+ )
113
+ )
114
+ svg_parts.append("</g>")
62
115
 
63
116
  # Services layer
64
117
  svg_parts.append('<g id="services-layer">')
65
118
  for service in services:
66
119
  if service.id in positions:
67
- svg_parts.append(self._render_service(service, positions[service.id]))
68
- svg_parts.append('</g>')
120
+ svg_parts.append(
121
+ self._render_service(
122
+ service,
123
+ positions[service.id],
124
+ aws_id_to_resource_id,
125
+ positions,
126
+ vpc_structure,
127
+ )
128
+ )
129
+ svg_parts.append("</g>")
69
130
 
70
- svg_parts.append('</svg>')
131
+ svg_parts.append("</svg>")
71
132
 
72
- return '\n'.join(svg_parts)
133
+ return "\n".join(svg_parts)
73
134
 
74
135
  def _render_defs(self) -> str:
75
136
  """Render SVG definitions (markers, filters)."""
76
- return '''
137
+ return """
77
138
  <defs>
78
139
  <marker id="arrowhead" markerWidth="10" markerHeight="7"
79
140
  refX="9" refY="3.5" orient="auto">
@@ -87,27 +148,39 @@ class SVGRenderer:
87
148
  refX="9" refY="3.5" orient="auto">
88
149
  <polygon points="0 0, 10 3.5, 0 7" fill="#E7157B"/>
89
150
  </marker>
151
+ <marker id="arrowhead-network" markerWidth="10" markerHeight="7"
152
+ refX="9" refY="3.5" orient="auto">
153
+ <polygon points="0 0, 10 3.5, 0 7" fill="#0d7c3f"/>
154
+ </marker>
155
+ <marker id="arrowhead-security" markerWidth="10" markerHeight="7"
156
+ refX="9" refY="3.5" orient="auto">
157
+ <polygon points="0 0, 10 3.5, 0 7" fill="#d97706"/>
158
+ </marker>
90
159
  <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
91
160
  <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
92
161
  </filter>
93
162
  </defs>
94
- '''
163
+ """
95
164
 
96
165
  def _render_group(self, group: ServiceGroup) -> str:
97
- """Render a group container (AWS Cloud, VPC)."""
166
+ """Render a group container (AWS Cloud, VPC, AZ)."""
98
167
  if not group.position:
99
- return ''
168
+ return ""
100
169
 
101
170
  pos = group.position
102
171
 
172
+ # Handle AZ groups with special rendering
173
+ if group.group_type == "az":
174
+ return self._render_az(group)
175
+
103
176
  colors = {
104
- 'aws_cloud': ('#232f3e', '#ffffff', '#232f3e'),
105
- 'vpc': ('#8c4fff', '#faf8ff', '#8c4fff'),
177
+ "aws_cloud": ("#232f3e", "#ffffff", "#232f3e"),
178
+ "vpc": ("#8c4fff", "#faf8ff", "#8c4fff"),
106
179
  }
107
180
 
108
- border_color, bg_color, text_color = colors.get(group.group_type, ('#666', '#fff', '#666'))
181
+ border_color, bg_color, text_color = colors.get(group.group_type, ("#666", "#fff", "#666"))
109
182
 
110
- return f'''
183
+ return f"""
111
184
  <g class="group group-{group.group_type}" data-group-type="{group.group_type}">
112
185
  <rect class="group-bg" x="{pos.x}" y="{pos.y}" width="{pos.width}" height="{pos.height}"
113
186
  fill="{bg_color}" stroke="{border_color}" stroke-width="2"
@@ -118,42 +191,225 @@ class SVGRenderer:
118
191
  font-family="Arial, sans-serif" font-size="14" font-weight="bold"
119
192
  fill="{text_color}">{html.escape(group.name)}</text>
120
193
  </g>
121
- '''
194
+ """
195
+
196
+ def _render_az(self, group: ServiceGroup) -> str:
197
+ """Render an Availability Zone container with dashed border."""
198
+ if not group.position:
199
+ return ""
200
+
201
+ pos = group.position
202
+ border_color = "#ff9900" # AWS orange for AZ
203
+ bg_color = "#fff8f0" # Light orange background
204
+ text_color = "#ff9900"
205
+
206
+ return f"""
207
+ <g class="group group-az" data-group-type="az">
208
+ <rect class="az-bg" x="{pos.x}" y="{pos.y}" width="{pos.width}" height="{pos.height}"
209
+ fill="{bg_color}" stroke="{border_color}" stroke-width="1.5"
210
+ stroke-dasharray="5,3" rx="8" ry="8"/>
211
+ <text x="{pos.x + 10}" y="{pos.y + 18}"
212
+ font-family="Arial, sans-serif" font-size="12" font-weight="bold"
213
+ fill="{text_color}">{html.escape(group.name)}</text>
214
+ </g>
215
+ """
216
+
217
+ def _render_subnet(self, subnet_id: str, pos: Position, subnet_info: "Subnet") -> str:
218
+ """Render a colored subnet box.
219
+
220
+ Colors:
221
+ - public: green
222
+ - private: blue
223
+ - database: yellow/gold
224
+ - unknown: gray
225
+ """
226
+ colors = {
227
+ "public": ("#22a06b", "#e3fcef"), # Green
228
+ "private": ("#0052cc", "#deebff"), # Blue
229
+ "database": ("#ff991f", "#fffae6"), # Yellow/Gold
230
+ "unknown": ("#6b778c", "#f4f5f7"), # Gray
231
+ }
232
+
233
+ border_color, bg_color = colors.get(subnet_info.subnet_type, colors["unknown"])
234
+
235
+ rt_label = ""
236
+ if subnet_info.route_table_name:
237
+ rt_label = f"""
238
+ <text x="{pos.x + pos.width - 8}" y="{pos.y + pos.height/2 + 16}"
239
+ font-family="Arial, sans-serif" font-size="9" fill="#999"
240
+ text-anchor="end" opacity="0.6">
241
+ RT: {html.escape(subnet_info.route_table_name)}
242
+ </text>"""
243
+
244
+ return f"""
245
+ <g class="subnet subnet-{subnet_info.subnet_type}" data-subnet-id="{html.escape(subnet_id)}"
246
+ data-min-x="{pos.x}" data-min-y="{pos.y}"
247
+ data-max-x="{pos.x + pos.width}" data-max-y="{pos.y + pos.height}">
248
+ <rect x="{pos.x}" y="{pos.y}" width="{pos.width}" height="{pos.height}"
249
+ fill="{bg_color}" stroke="{border_color}" stroke-width="1.5" rx="4" ry="4"/>
250
+ <text x="{pos.x + 8}" y="{pos.y + pos.height/2 + 4}"
251
+ font-family="Arial, sans-serif" font-size="11" fill="{border_color}">
252
+ {html.escape(subnet_info.name)}
253
+ </text>
254
+ <text x="{pos.x + pos.width - 8}" y="{pos.y + pos.height/2 + 4}"
255
+ font-family="Arial, sans-serif" font-size="10" fill="{border_color}"
256
+ text-anchor="end" opacity="0.7">
257
+ {html.escape(subnet_info.cidr_block) if subnet_info.cidr_block else subnet_info.subnet_type}
258
+ </text>{rt_label}
259
+ </g>
260
+ """
261
+
262
+ def _render_vpc_endpoint(
263
+ self, endpoint_id: str, pos: Position, endpoint_info: "VPCEndpoint"
264
+ ) -> str:
265
+ """Render a VPC endpoint with AWS icon and service name.
266
+
267
+ Colors:
268
+ - gateway: green (S3, DynamoDB)
269
+ - interface: blue (ECR, CloudWatch, SSM, etc.)
270
+ """
271
+ # Colors by type
272
+ colors = {
273
+ "gateway": ("#22a06b", "#e3fcef"), # Green
274
+ "interface": ("#0052cc", "#deebff"), # Blue
275
+ }
276
+ border_color, bg_color = colors.get(endpoint_info.endpoint_type, colors["interface"])
277
+
278
+ # Extract clean service name
279
+ service_name = endpoint_info.service
280
+ if "." in service_name:
281
+ service_name = service_name.split(".")[0]
282
+ service_display = service_name.upper()
283
+
284
+ # Type label
285
+ type_label = "Gateway" if endpoint_info.endpoint_type == "gateway" else "Interface"
286
+
287
+ # Box dimensions
288
+ box_width = pos.width
289
+ box_height = pos.height
290
+
291
+ # Center positions
292
+ cx = pos.x + box_width / 2
122
293
 
123
- def _render_service(self, service: LogicalService, pos: Position) -> str:
294
+ # Try to get official AWS VPC Endpoints icon
295
+ icon_svg = self.icon_mapper.get_icon_svg("aws_vpc_endpoint", 48)
296
+ icon_content = None
297
+
298
+ # Check if we got a real icon (not the fallback with "RES" text)
299
+ if icon_svg and "Endpoints" in icon_svg:
300
+ icon_content = self._extract_svg_content(icon_svg)
301
+
302
+ if icon_content:
303
+ # Use official AWS icon
304
+ icon_size = 32
305
+ return f"""
306
+ <g class="vpc-endpoint endpoint-{endpoint_info.endpoint_type}" data-endpoint-id="{html.escape(endpoint_id)}">
307
+ <rect x="{pos.x}" y="{pos.y}" width="{box_width}" height="{box_height}"
308
+ fill="white" stroke="#e0e0e0" stroke-width="1" rx="6" ry="6"
309
+ filter="url(#shadow)"/>
310
+ <svg x="{cx - icon_size/2}" y="{pos.y + 6}" width="{icon_size}" height="{icon_size}" viewBox="0 0 48 48">
311
+ {icon_content}
312
+ </svg>
313
+ <text x="{cx}" y="{pos.y + 48}"
314
+ font-family="Arial, sans-serif" font-size="10" fill="#333"
315
+ text-anchor="middle" font-weight="bold">
316
+ {html.escape(service_display)}
317
+ </text>
318
+ <text x="{cx}" y="{pos.y + 60}"
319
+ font-family="Arial, sans-serif" font-size="8" fill="#666"
320
+ text-anchor="middle">
321
+ {type_label}
322
+ </text>
323
+ <title>{html.escape(endpoint_info.name)} ({endpoint_info.endpoint_type} endpoint for {service_name})</title>
324
+ </g>
325
+ """
326
+ else:
327
+ # Fallback: colored box with service name
328
+ return f"""
329
+ <g class="vpc-endpoint endpoint-{endpoint_info.endpoint_type}" data-endpoint-id="{html.escape(endpoint_id)}">
330
+ <rect x="{pos.x}" y="{pos.y}" width="{box_width}" height="{box_height}"
331
+ fill="{bg_color}" stroke="{border_color}" stroke-width="1.5" rx="6" ry="6"
332
+ filter="url(#shadow)"/>
333
+ <text x="{cx}" y="{pos.y + box_height/2 - 6}"
334
+ font-family="Arial, sans-serif" font-size="11" fill="{border_color}"
335
+ text-anchor="middle" font-weight="bold">
336
+ {html.escape(service_display)}
337
+ </text>
338
+ <text x="{cx}" y="{pos.y + box_height/2 + 8}"
339
+ font-family="Arial, sans-serif" font-size="9" fill="{border_color}"
340
+ text-anchor="middle" opacity="0.7">
341
+ {type_label}
342
+ </text>
343
+ <title>{html.escape(endpoint_info.name)} ({endpoint_info.endpoint_type} endpoint for {service_name})</title>
344
+ </g>
345
+ """
346
+
347
+ def _render_service(
348
+ self,
349
+ service: LogicalService,
350
+ pos: Position,
351
+ aws_id_to_resource_id: Optional[Dict[str, str]] = None,
352
+ all_positions: Optional[Dict[str, Position]] = None,
353
+ vpc_structure: Optional["VPCStructure"] = None,
354
+ ) -> str:
124
355
  """Render a draggable logical service with its icon."""
125
356
  icon_svg = self.icon_mapper.get_icon_svg(service.icon_resource_type, 48)
126
357
  color = self.icon_mapper.get_category_color(service.icon_resource_type)
127
358
 
128
359
  # Count badge
129
- count_badge = ''
360
+ count_badge = ""
130
361
  if service.count > 1:
131
- count_badge = f'''
362
+ count_badge = f"""
132
363
  <circle class="count-badge" cx="{pos.width - 8}" cy="8" r="12"
133
364
  fill="{color}" stroke="white" stroke-width="2"/>
134
365
  <text class="count-text" x="{pos.width - 8}" y="12"
135
366
  font-family="Arial, sans-serif" font-size="11" fill="white"
136
367
  text-anchor="middle" font-weight="bold">{service.count}</text>
137
- '''
368
+ """
138
369
 
139
370
  resource_count = len(service.resources)
140
371
  tooltip = f"{service.name} ({resource_count} resources)"
141
372
 
142
373
  # Determine if this is a VPC service
143
- is_vpc_service = 'true' if service.is_vpc_resource else 'false'
374
+ is_vpc_service = "true" if service.is_vpc_resource else "false"
375
+
376
+ # Determine subnet constraint directly from service.subnet_ids
377
+ # This ensures the drag constraint matches the service's actual subnet assignment
378
+ subnet_attr = ""
379
+ if service.subnet_ids and vpc_structure and all_positions:
380
+ # Map from AWS IDs to resource IDs for state-based lookups
381
+ for subnet_id in service.subnet_ids:
382
+ resolved_id = subnet_id
383
+ # Handle _state_subnet: prefixed IDs (from Terraform state)
384
+ if subnet_id.startswith("_state_subnet:") and aws_id_to_resource_id:
385
+ aws_id = subnet_id[len("_state_subnet:") :]
386
+ resolved_id = aws_id_to_resource_id.get(aws_id)
387
+
388
+ # Find the subnet that contains this service's position
389
+ if resolved_id and resolved_id in all_positions:
390
+ subnet_pos = all_positions[resolved_id]
391
+ # Check if service position is inside this subnet
392
+ if (
393
+ subnet_pos.x <= pos.x <= subnet_pos.x + subnet_pos.width
394
+ and subnet_pos.y <= pos.y <= subnet_pos.y + subnet_pos.height
395
+ ):
396
+ subnet_attr = f'data-subnet-id="{html.escape(resolved_id)}"'
397
+ break
144
398
 
145
399
  if icon_svg:
146
400
  icon_content = self._extract_svg_content(icon_svg)
401
+ icon_viewbox = self._extract_svg_viewbox(icon_svg)
147
402
 
148
- svg = f'''
403
+ svg = f"""
149
404
  <g class="service draggable" data-service-id="{html.escape(service.id)}"
150
- data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}"
405
+ data-service-type="{html.escape(service.service_type)}"
406
+ data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}" {subnet_attr}
151
407
  transform="translate({pos.x}, {pos.y})" style="cursor: grab;">
152
408
  <rect class="service-bg" x="-8" y="-8"
153
409
  width="{pos.width + 16}" height="{pos.height + 36}"
154
410
  fill="white" stroke="#e0e0e0" stroke-width="1" rx="8" ry="8"
155
411
  filter="url(#shadow)"/>
156
- <svg class="service-icon" width="{pos.width}" height="{pos.height}" viewBox="0 0 64 64">
412
+ <svg class="service-icon" width="{pos.width}" height="{pos.height}" viewBox="{icon_viewbox}">
157
413
  {icon_content}
158
414
  </svg>
159
415
  <text class="service-label" x="{pos.width/2}" y="{pos.height + 16}"
@@ -163,11 +419,12 @@ class SVGRenderer:
163
419
  </text>
164
420
  {count_badge}
165
421
  </g>
166
- '''
422
+ """
167
423
  else:
168
- svg = f'''
424
+ svg = f"""
169
425
  <g class="service draggable" data-service-id="{html.escape(service.id)}"
170
- data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}"
426
+ data-service-type="{html.escape(service.service_type)}"
427
+ data-tooltip="{html.escape(tooltip)}" data-is-vpc="{is_vpc_service}" {subnet_attr}
171
428
  transform="translate({pos.x}, {pos.y})" style="cursor: grab;">
172
429
  <rect class="service-bg" x="-8" y="-8"
173
430
  width="{pos.width + 16}" height="{pos.height + 36}"
@@ -185,34 +442,47 @@ class SVGRenderer:
185
442
  </text>
186
443
  {count_badge}
187
444
  </g>
188
- '''
445
+ """
189
446
 
190
447
  return svg
191
448
 
192
449
  def _extract_svg_content(self, svg_string: str) -> str:
193
450
  """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)
451
+ svg_string = re.sub(r"<\?xml[^?]*\?>\s*", "", svg_string)
452
+ match = re.search(r"<svg[^>]*>(.*)</svg>", svg_string, re.DOTALL)
453
+ if match:
454
+ return match.group(1)
455
+ return ""
456
+
457
+ def _extract_svg_viewbox(self, svg_string: str) -> str:
458
+ """Extract the viewBox attribute from an SVG string."""
459
+ match = re.search(r'viewBox=["\']([^"\']+)["\']', svg_string)
196
460
  if match:
197
461
  return match.group(1)
198
- return ''
462
+ # Default to 64 64 for Architecture icons
463
+ return "0 0 64 64"
199
464
 
200
465
  def _render_connection(
201
466
  self,
202
467
  source_pos: Position,
203
468
  target_pos: Position,
204
- connection: LogicalConnection
469
+ connection: LogicalConnection,
470
+ service_type_map: Optional[Dict[str, str]] = None,
205
471
  ) -> str:
206
472
  """Render a connection line between services."""
207
473
  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)'),
474
+ "data_flow": ("#3B48CC", "", "url(#arrowhead-data)"),
475
+ "trigger": ("#E7157B", "", "url(#arrowhead-trigger)"),
476
+ "encrypt": ("#6c757d", "4,4", "url(#arrowhead)"),
477
+ "network_flow": ("#0d7c3f", "", "url(#arrowhead-network)"),
478
+ "security_rule": ("#d97706", "2,4", "url(#arrowhead-security)"),
479
+ "default": ("#999999", "", "url(#arrowhead)"),
212
480
  }
213
481
 
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 ''
482
+ stroke_color, stroke_dash, marker = styles.get(
483
+ connection.connection_type, styles["default"]
484
+ )
485
+ dash_attr = f'stroke-dasharray="{stroke_dash}"' if stroke_dash else ""
216
486
 
217
487
  # Calculate initial path
218
488
  half_size = self.config.icon_size / 2
@@ -244,23 +514,30 @@ class SVGRenderer:
244
514
  mid_y = (sy + ty) / 2
245
515
  path = f"M {sx} {sy} Q {mid_x} {sy}, {mid_x} {mid_y} T {tx} {ty}"
246
516
 
247
- label = connection.label or ''
248
- return f'''
517
+ label = connection.label or ""
518
+ source_type = ""
519
+ target_type = ""
520
+ if service_type_map:
521
+ source_type = service_type_map.get(connection.source_id, "")
522
+ target_type = service_type_map.get(connection.target_id, "")
523
+ return f"""
249
524
  <g class="connection" data-source="{html.escape(connection.source_id)}"
250
525
  data-target="{html.escape(connection.target_id)}"
526
+ data-source-type="{html.escape(source_type)}"
527
+ data-target-type="{html.escape(target_type)}"
251
528
  data-conn-type="{connection.connection_type}"
252
529
  data-label="{html.escape(label)}">
253
- <path class="connection-hitarea" d="{path}"/>
530
+ <path class="connection-hitarea" d="{path}" fill="none" stroke="transparent" stroke-width="15"/>
254
531
  <path class="connection-path" d="{path}" fill="none" stroke="{stroke_color}"
255
532
  stroke-width="1.5" {dash_attr} marker-end="{marker}" opacity="0.7"/>
256
533
  </g>
257
- '''
534
+ """
258
535
 
259
536
 
260
537
  class HTMLRenderer:
261
538
  """Wraps SVG in interactive HTML with drag-and-drop and export."""
262
539
 
263
- HTML_TEMPLATE = '''<!DOCTYPE html>
540
+ HTML_TEMPLATE = """<!DOCTYPE html>
264
541
  <html lang="en">
265
542
  <head>
266
543
  <meta charset="UTF-8">
@@ -272,7 +549,7 @@ class HTMLRenderer:
272
549
  margin: 0;
273
550
  padding: 20px;
274
551
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
275
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
552
+ background: #2d2d2d;
276
553
  min-height: 100vh;
277
554
  }}
278
555
  .container {{
@@ -349,7 +626,7 @@ class HTMLRenderer:
349
626
  background: #dee2e6;
350
627
  }}
351
628
  .diagram-container {{
352
- background: white;
629
+ background: #f8f9fa;
353
630
  border-radius: 12px;
354
631
  box-shadow: 0 4px 6px rgba(0,0,0,0.1);
355
632
  overflow: hidden;
@@ -385,13 +662,48 @@ class HTMLRenderer:
385
662
  border-color: #ccc;
386
663
  }}
387
664
  .diagram-wrapper {{
388
- padding: 20px;
389
- overflow: auto;
390
- max-height: 70vh;
665
+ padding: 10px;
666
+ overflow: visible;
391
667
  }}
392
668
  .diagram-wrapper svg {{
393
669
  display: block;
394
670
  margin: 0 auto;
671
+ width: 100%;
672
+ height: auto;
673
+ max-height: none;
674
+ }}
675
+ @media (max-width: 1200px) {{
676
+ .header {{
677
+ flex-direction: column;
678
+ gap: 15px;
679
+ }}
680
+ .header-right {{
681
+ flex-direction: column;
682
+ width: 100%;
683
+ }}
684
+ .stats {{
685
+ justify-content: center;
686
+ }}
687
+ .export-buttons {{
688
+ justify-content: center;
689
+ }}
690
+ }}
691
+ @media (max-width: 768px) {{
692
+ body {{
693
+ padding: 10px;
694
+ }}
695
+ .header h1 {{
696
+ font-size: 18px;
697
+ }}
698
+ .stats {{
699
+ gap: 15px;
700
+ }}
701
+ .stat-value {{
702
+ font-size: 20px;
703
+ }}
704
+ .legend-grid {{
705
+ grid-template-columns: 1fr;
706
+ }}
395
707
  }}
396
708
  .service.dragging {{
397
709
  opacity: 0.8;
@@ -430,6 +742,47 @@ class HTMLRenderer:
430
742
  fill: none;
431
743
  cursor: pointer;
432
744
  }}
745
+ /* Spoke connection styles */
746
+ .spoke-connection {{
747
+ cursor: pointer;
748
+ transition: opacity 0.2s;
749
+ }}
750
+ .spoke-connection:hover .spoke-path {{
751
+ stroke-width: 4 !important;
752
+ opacity: 1 !important;
753
+ }}
754
+ .spoke-connection.highlighted .spoke-path {{
755
+ stroke-width: 4 !important;
756
+ opacity: 1 !important;
757
+ }}
758
+ .spoke-connection.dimmed {{
759
+ opacity: 0.15 !important;
760
+ }}
761
+ .spoke-hitarea {{
762
+ stroke: transparent;
763
+ stroke-width: 20;
764
+ fill: none;
765
+ cursor: pointer;
766
+ }}
767
+ /* Spoke rays - subtle lines from edge point to service icons */
768
+ .spoke-ray {{
769
+ stroke: #bbb;
770
+ stroke-width: 1;
771
+ opacity: 0.3;
772
+ transition: opacity 0.2s, stroke 0.2s;
773
+ }}
774
+ .spoke-rays.highlighted .spoke-ray {{
775
+ opacity: 0.6;
776
+ stroke: #888;
777
+ }}
778
+ .spoke-ray.highlighted {{
779
+ opacity: 0.8 !important;
780
+ stroke: #666 !important;
781
+ stroke-width: 1.5 !important;
782
+ }}
783
+ .spoke-ray.dimmed {{
784
+ opacity: 0.1 !important;
785
+ }}
433
786
  .legend {{
434
787
  margin-top: 20px;
435
788
  padding: 20px 25px;
@@ -465,9 +818,23 @@ class HTMLRenderer:
465
818
  font-size: 13px;
466
819
  }}
467
820
  .legend-line {{
468
- width: 30px;
469
- height: 3px;
470
- border-radius: 2px;
821
+ width: 36px;
822
+ height: 14px;
823
+ flex-shrink: 0;
824
+ }}
825
+ .legend-line svg {{
826
+ display: block;
827
+ }}
828
+ .legend-box {{
829
+ width: 24px;
830
+ height: 16px;
831
+ border-radius: 3px;
832
+ border: 1.5px solid;
833
+ }}
834
+ .legend-circle {{
835
+ width: 20px;
836
+ height: 20px;
837
+ border-radius: 50%;
471
838
  }}
472
839
  .tooltip {{
473
840
  position: fixed;
@@ -541,6 +908,168 @@ class HTMLRenderer:
541
908
  margin-top: 8px;
542
909
  font-size: 11px;
543
910
  }}
911
+ /* ============ AGGREGATION UI ============ */
912
+ .aggregation-panel {{
913
+ margin-top: 15px;
914
+ padding: 15px 20px;
915
+ background: white;
916
+ border-radius: 12px;
917
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
918
+ display: flex;
919
+ align-items: center;
920
+ flex-wrap: wrap;
921
+ gap: 10px;
922
+ }}
923
+ .aggregation-panel-label {{
924
+ font-size: 13px;
925
+ font-weight: 600;
926
+ color: #232f3e;
927
+ margin-right: 5px;
928
+ }}
929
+ .aggregation-chip {{
930
+ display: inline-flex;
931
+ align-items: center;
932
+ gap: 6px;
933
+ padding: 6px 14px;
934
+ border-radius: 20px;
935
+ font-size: 12px;
936
+ font-weight: 500;
937
+ cursor: pointer;
938
+ transition: all 0.2s;
939
+ user-select: none;
940
+ border: 2px solid;
941
+ }}
942
+ .aggregation-chip.active {{
943
+ color: white;
944
+ }}
945
+ .aggregation-chip.inactive {{
946
+ background: transparent;
947
+ }}
948
+ .aggregation-chip:hover {{
949
+ transform: translateY(-1px);
950
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
951
+ }}
952
+ .aggregation-chip .chip-check {{
953
+ font-size: 11px;
954
+ }}
955
+ /* Aggregate node styles */
956
+ .aggregate-node .service-bg {{
957
+ stroke-dasharray: 6,3 !important;
958
+ stroke-width: 2 !important;
959
+ }}
960
+ .aggregate-node {{
961
+ cursor: pointer;
962
+ }}
963
+ .aggregate-badge {{
964
+ pointer-events: none;
965
+ }}
966
+ /* Aggregate connection styles */
967
+ .aggregate-connection .connection-path {{
968
+ opacity: 0.6;
969
+ }}
970
+ .aggregate-connection .multiplicity-label {{
971
+ font-family: Arial, sans-serif;
972
+ font-size: 10px;
973
+ font-weight: bold;
974
+ fill: #666;
975
+ }}
976
+ /* Popover styles */
977
+ .aggregate-popover {{
978
+ position: fixed;
979
+ background: white;
980
+ border-radius: 10px;
981
+ box-shadow: 0 8px 30px rgba(0,0,0,0.2);
982
+ z-index: 1500;
983
+ max-height: 300px;
984
+ overflow-y: auto;
985
+ min-width: 220px;
986
+ padding: 8px 0;
987
+ }}
988
+ .aggregate-popover-header {{
989
+ padding: 8px 16px;
990
+ font-size: 12px;
991
+ font-weight: 600;
992
+ color: #666;
993
+ border-bottom: 1px solid #eee;
994
+ text-transform: uppercase;
995
+ }}
996
+ .aggregate-popover-item {{
997
+ display: flex;
998
+ align-items: center;
999
+ gap: 10px;
1000
+ padding: 8px 16px;
1001
+ cursor: pointer;
1002
+ font-size: 13px;
1003
+ color: #333;
1004
+ transition: background 0.15s;
1005
+ }}
1006
+ .aggregate-popover-item:hover {{
1007
+ background: #f5f5f5;
1008
+ }}
1009
+ .aggregate-popover-item.selected {{
1010
+ background: #ede7f6;
1011
+ color: #6200ea;
1012
+ font-weight: 500;
1013
+ }}
1014
+ .aggregate-popover-item svg {{
1015
+ width: 24px;
1016
+ height: 24px;
1017
+ flex-shrink: 0;
1018
+ }}
1019
+ /* Transition animations for aggregation */
1020
+ .service.agg-hidden {{
1021
+ display: none !important;
1022
+ }}
1023
+ .connection.agg-hidden {{
1024
+ display: none !important;
1025
+ }}
1026
+ .connection.conn-type-hidden {{
1027
+ display: none !important;
1028
+ }}
1029
+ /* ============ CONNECTION TYPE FILTER UI ============ */
1030
+ .conn-filter-panel {{
1031
+ margin-top: 15px;
1032
+ padding: 15px 20px;
1033
+ background: white;
1034
+ border-radius: 12px;
1035
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
1036
+ display: flex;
1037
+ align-items: center;
1038
+ flex-wrap: wrap;
1039
+ gap: 10px;
1040
+ }}
1041
+ .conn-filter-panel-label {{
1042
+ font-size: 13px;
1043
+ font-weight: 600;
1044
+ color: #232f3e;
1045
+ margin-right: 5px;
1046
+ }}
1047
+ .conn-filter-chip {{
1048
+ display: inline-flex;
1049
+ align-items: center;
1050
+ gap: 6px;
1051
+ padding: 6px 14px;
1052
+ border-radius: 20px;
1053
+ font-size: 12px;
1054
+ font-weight: 500;
1055
+ cursor: pointer;
1056
+ transition: all 0.2s;
1057
+ user-select: none;
1058
+ border: 2px solid;
1059
+ }}
1060
+ .conn-filter-chip.active {{
1061
+ color: white;
1062
+ }}
1063
+ .conn-filter-chip.inactive {{
1064
+ background: transparent;
1065
+ }}
1066
+ .conn-filter-chip:hover {{
1067
+ transform: translateY(-1px);
1068
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
1069
+ }}
1070
+ .conn-filter-chip .chip-check {{
1071
+ font-size: 11px;
1072
+ }}
544
1073
  </style>
545
1074
  </head>
546
1075
  <body>
@@ -584,6 +1113,14 @@ class HTMLRenderer:
584
1113
  {svg_content}
585
1114
  </div>
586
1115
  </div>
1116
+ <div class="aggregation-panel" id="aggregation-panel" style="display:none;">
1117
+ <span class="aggregation-panel-label">Aggregation:</span>
1118
+ <div id="aggregation-chips" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
1119
+ </div>
1120
+ <div class="conn-filter-panel" id="conn-filter-panel">
1121
+ <span class="conn-filter-panel-label">Connections:</span>
1122
+ <div id="conn-filter-chips" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
1123
+ </div>
587
1124
  <div class="legend">
588
1125
  <h3>Legend</h3>
589
1126
  <div class="legend-grid">
@@ -591,30 +1128,63 @@ class HTMLRenderer:
591
1128
  <h4>Connection Types</h4>
592
1129
  <div class="legend-items">
593
1130
  <div class="legend-item">
594
- <div class="legend-line" style="background: #3B48CC;"></div>
1131
+ <div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-data" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#3B48CC"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#3B48CC" stroke-width="2" marker-end="url(#lm-data)"/></svg></div>
595
1132
  <span>Data Flow</span>
596
1133
  </div>
597
1134
  <div class="legend-item">
598
- <div class="legend-line" style="background: #E7157B;"></div>
1135
+ <div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-trigger" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#E7157B"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#E7157B" stroke-width="2" marker-end="url(#lm-trigger)"/></svg></div>
599
1136
  <span>Event Trigger</span>
600
1137
  </div>
601
1138
  <div class="legend-item">
602
- <div class="legend-line" style="background: #6c757d;"></div>
1139
+ <div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-encrypt" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#6c757d"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#6c757d" stroke-width="2" stroke-dasharray="4,3" marker-end="url(#lm-encrypt)"/></svg></div>
603
1140
  <span>Encryption</span>
604
1141
  </div>
605
1142
  <div class="legend-item">
606
- <div class="legend-line" style="background: #999;"></div>
1143
+ <div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-ref" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#999"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#999" stroke-width="2" marker-end="url(#lm-ref)"/></svg></div>
607
1144
  <span>Reference</span>
608
1145
  </div>
1146
+ <div class="legend-item">
1147
+ <div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-network" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#0d7c3f"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#0d7c3f" stroke-width="2" marker-end="url(#lm-network)"/></svg></div>
1148
+ <span>Network Flow</span>
1149
+ </div>
1150
+ <div class="legend-item">
1151
+ <div class="legend-line"><svg width="36" height="14" xmlns="http://www.w3.org/2000/svg"><defs><marker id="lm-security" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#d97706"/></marker></defs><line x1="0" y1="7" x2="28" y2="7" stroke="#d97706" stroke-width="2" stroke-dasharray="2,4" marker-end="url(#lm-security)"/></svg></div>
1152
+ <span>Security Rule</span>
1153
+ </div>
609
1154
  </div>
610
1155
  </div>
611
1156
  <div class="legend-section">
612
- <h4>Instructions</h4>
1157
+ <h4>Subnet Types</h4>
613
1158
  <div class="legend-items">
1159
+ <div class="legend-item">
1160
+ <div class="legend-box" style="background: #e3fcef; border-color: #22a06b;"></div>
1161
+ <span>Public Subnet</span>
1162
+ </div>
1163
+ <div class="legend-item">
1164
+ <div class="legend-box" style="background: #deebff; border-color: #0052cc;"></div>
1165
+ <span>Private Subnet</span>
1166
+ </div>
1167
+ <div class="legend-item">
1168
+ <div class="legend-box" style="background: #fffae6; border-color: #ff991f;"></div>
1169
+ <span>Database Subnet</span>
1170
+ </div>
1171
+ </div>
1172
+ </div>
1173
+ <div class="legend-section">
1174
+ <h4>VPC Endpoints</h4>
1175
+ <div class="legend-items">
1176
+ <div class="legend-item"><span><strong>Gateway</strong> &mdash; S3, DynamoDB</span></div>
1177
+ <div class="legend-item"><span><strong>Interface</strong> &mdash; ECR, Logs, etc.</span></div>
1178
+ </div>
1179
+ </div>
1180
+ <div class="legend-section">
1181
+ <h4>Interactions</h4>
1182
+ <div class="legend-items">
1183
+ <div class="legend-item">Click service to highlight connections</div>
1184
+ <div class="legend-item">Click connection to highlight endpoints</div>
614
1185
  <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>
1186
+ <div class="legend-item">Toggle connection types and aggregation</div>
1187
+ <div class="legend-item">Save/Load to persist layout</div>
618
1188
  </div>
619
1189
  </div>
620
1190
  </div>
@@ -634,6 +1204,10 @@ class HTMLRenderer:
634
1204
  </div>
635
1205
  </div>
636
1206
 
1207
+ <script>
1208
+ // Aggregation configuration (injected by Python)
1209
+ const AGGREGATION_CONFIG = {aggregation_config_json};
1210
+ </script>
637
1211
  <script>
638
1212
  // Service positions storage
639
1213
  const servicePositions = {{}};
@@ -647,6 +1221,8 @@ class HTMLRenderer:
647
1221
  initHighlighting();
648
1222
  updateAllConnections();
649
1223
  saveOriginalPositions();
1224
+ initAggregation();
1225
+ initConnectionTypeFilter();
650
1226
  }});
651
1227
 
652
1228
  function saveOriginalPositions() {{
@@ -666,24 +1242,38 @@ class HTMLRenderer:
666
1242
  let dragging = null;
667
1243
  let offset = {{ x: 0, y: 0 }};
668
1244
 
669
- document.querySelectorAll('.service.draggable').forEach(el => {{
670
- el.addEventListener('mousedown', startDrag);
1245
+ // Use event delegation on SVG for mousedown to handle dynamically created nodes
1246
+ svg.addEventListener('mousedown', (e) => {{
1247
+ const target = e.target.closest('.service.draggable');
1248
+ if (target) startDrag(e, target);
671
1249
  }});
672
-
673
1250
  svg.addEventListener('mousemove', drag);
674
1251
  svg.addEventListener('mouseup', endDrag);
675
1252
  svg.addEventListener('mouseleave', endDrag);
676
1253
 
677
- function startDrag(e) {{
1254
+ function startDrag(e, targetEl) {{
678
1255
  e.preventDefault();
679
- dragging = e.currentTarget;
1256
+
1257
+ // Guard against null CTM (can happen during rendering)
1258
+ const ctm = svg.getScreenCTM();
1259
+ if (!ctm) return;
1260
+
1261
+ dragging = targetEl;
680
1262
  dragging.classList.add('dragging');
681
1263
  dragging.style.cursor = 'grabbing';
682
1264
 
683
1265
  const pt = svg.createSVGPoint();
684
1266
  pt.x = e.clientX;
685
1267
  pt.y = e.clientY;
686
- const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
1268
+ const svgP = pt.matrixTransform(ctm.inverse());
1269
+
1270
+ // Validate coordinates to prevent NaN issues
1271
+ if (isNaN(svgP.x) || isNaN(svgP.y)) {{
1272
+ dragging.classList.remove('dragging');
1273
+ dragging.style.cursor = 'grab';
1274
+ dragging = null;
1275
+ return;
1276
+ }}
687
1277
 
688
1278
  const id = dragging.dataset.serviceId;
689
1279
  const pos = servicePositions[id] || {{ x: 0, y: 0 }};
@@ -697,16 +1287,38 @@ class HTMLRenderer:
697
1287
  function drag(e) {{
698
1288
  if (!dragging) return;
699
1289
 
1290
+ // Guard against null CTM
1291
+ const ctm = svg.getScreenCTM();
1292
+ if (!ctm) return;
1293
+
700
1294
  const pt = svg.createSVGPoint();
701
1295
  pt.x = e.clientX;
702
1296
  pt.y = e.clientY;
703
- const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
1297
+ const svgP = pt.matrixTransform(ctm.inverse());
1298
+
1299
+ // Validate coordinates to prevent NaN issues
1300
+ if (isNaN(svgP.x) || isNaN(svgP.y)) return;
704
1301
 
705
1302
  let newX = svgP.x - offset.x;
706
1303
  let newY = svgP.y - offset.y;
707
1304
 
708
- // Constrain to VPC if it's a VPC service
709
- if (dragging.dataset.isVpc === 'true') {{
1305
+ // Check if service belongs to a specific subnet
1306
+ const subnetId = dragging.dataset.subnetId;
1307
+ if (subnetId) {{
1308
+ // Constrain to subnet bounds
1309
+ const subnetGroup = document.querySelector(`.subnet[data-subnet-id="${{subnetId}}"]`);
1310
+ if (subnetGroup) {{
1311
+ const padding = 10;
1312
+ const minX = parseFloat(subnetGroup.dataset.minX) + padding;
1313
+ const minY = parseFloat(subnetGroup.dataset.minY) + padding;
1314
+ const maxX = parseFloat(subnetGroup.dataset.maxX) - iconSize - padding;
1315
+ const maxY = parseFloat(subnetGroup.dataset.maxY) - iconSize - padding;
1316
+
1317
+ newX = Math.max(minX, Math.min(maxX, newX));
1318
+ newY = Math.max(minY, Math.min(maxY, newY));
1319
+ }}
1320
+ }} else if (dragging.dataset.isVpc === 'true') {{
1321
+ // Constrain to VPC bounds
710
1322
  const vpcGroup = document.querySelector('.group-vpc .group-bg');
711
1323
  if (vpcGroup) {{
712
1324
  const minX = parseFloat(vpcGroup.dataset.minX) + 20;
@@ -716,18 +1328,75 @@ class HTMLRenderer:
716
1328
 
717
1329
  newX = Math.max(minX, Math.min(maxX, newX));
718
1330
  newY = Math.max(minY, Math.min(maxY, newY));
1331
+
1332
+ // VPC services without subnet assignment cannot enter subnet areas
1333
+ if (!dragging.dataset.subnetId) {{
1334
+ document.querySelectorAll('.subnet').forEach(sub => {{
1335
+ const sMinX = parseFloat(sub.dataset.minX);
1336
+ const sMinY = parseFloat(sub.dataset.minY);
1337
+ const sMaxX = parseFloat(sub.dataset.maxX);
1338
+ const sMaxY = parseFloat(sub.dataset.maxY);
1339
+ const nodeR = newX + iconSize;
1340
+ const nodeB = newY + iconSize;
1341
+ if (nodeR > sMinX && newX < sMaxX && nodeB > sMinY && newY < sMaxY) {{
1342
+ const dL = Math.abs(nodeR - sMinX);
1343
+ const dR = Math.abs(newX - sMaxX);
1344
+ const dT = Math.abs(nodeB - sMinY);
1345
+ const dB = Math.abs(newY - sMaxY);
1346
+ const m = Math.min(dL, dR, dT, dB);
1347
+ if (m === dT) newY = sMinY - iconSize;
1348
+ else if (m === dB) newY = sMaxY;
1349
+ else if (m === dL) newX = sMinX - iconSize;
1350
+ else newX = sMaxX;
1351
+ }}
1352
+ }});
1353
+ }}
719
1354
  }}
720
1355
  }} else {{
721
- // Constrain to AWS Cloud bounds
1356
+ // AWS Cloud bounds - expandable downward
722
1357
  const cloudGroup = document.querySelector('.group-aws_cloud .group-bg');
723
1358
  if (cloudGroup) {{
724
1359
  const minX = parseFloat(cloudGroup.dataset.minX) + 20;
725
1360
  const minY = parseFloat(cloudGroup.dataset.minY) + 40;
726
1361
  const maxX = parseFloat(cloudGroup.dataset.maxX) - iconSize - 20;
727
- const maxY = parseFloat(cloudGroup.dataset.maxY) - iconSize - 40;
1362
+ const currentMaxY = parseFloat(cloudGroup.dataset.maxY);
728
1363
 
1364
+ // Constrain X and minY, but allow expansion downward
729
1365
  newX = Math.max(minX, Math.min(maxX, newX));
730
- newY = Math.max(minY, Math.min(maxY, newY));
1366
+ newY = Math.max(minY, newY);
1367
+
1368
+ // Prevent global services from entering the VPC area
1369
+ const vpcBg = document.querySelector('.group-vpc .group-bg');
1370
+ if (vpcBg) {{
1371
+ const vpcMinX = parseFloat(vpcBg.dataset.minX);
1372
+ const vpcMinY = parseFloat(vpcBg.dataset.minY);
1373
+ const vpcMaxX = parseFloat(vpcBg.dataset.maxX);
1374
+ const vpcMaxY = parseFloat(vpcBg.dataset.maxY);
1375
+ const nodeRight = newX + iconSize;
1376
+ const nodeBottom = newY + iconSize;
1377
+
1378
+ // Check if node overlaps VPC box
1379
+ if (nodeRight > vpcMinX && newX < vpcMaxX &&
1380
+ nodeBottom > vpcMinY && newY < vpcMaxY) {{
1381
+ // Push to nearest edge outside VPC
1382
+ const distLeft = Math.abs(nodeRight - vpcMinX);
1383
+ const distRight = Math.abs(newX - vpcMaxX);
1384
+ const distTop = Math.abs(nodeBottom - vpcMinY);
1385
+ const distBottom = Math.abs(newY - vpcMaxY);
1386
+ const minDist = Math.min(distLeft, distRight, distTop, distBottom);
1387
+
1388
+ if (minDist === distTop) newY = vpcMinY - iconSize;
1389
+ else if (minDist === distBottom) newY = vpcMaxY;
1390
+ else if (minDist === distLeft) newX = vpcMinX - iconSize;
1391
+ else newX = vpcMaxX;
1392
+ }}
1393
+ }}
1394
+
1395
+ // Expand AWS Cloud box and canvas if dragging below current bounds
1396
+ const requiredBottom = newY + iconSize + 40;
1397
+ if (requiredBottom > currentMaxY) {{
1398
+ expandCanvas(requiredBottom);
1399
+ }}
731
1400
  }}
732
1401
  }}
733
1402
 
@@ -747,13 +1416,48 @@ class HTMLRenderer:
747
1416
  }}
748
1417
  }}
749
1418
 
750
- function updateConnectionsFor(serviceId) {{
1419
+ function expandCanvas(newBottom) {{
1420
+ const svg = document.getElementById('diagram-svg');
1421
+ const cloudGroup = document.querySelector('.group-aws_cloud .group-bg');
1422
+
1423
+ if (!cloudGroup || !svg) return;
1424
+
1425
+ // Get current viewBox
1426
+ const viewBox = svg.getAttribute('viewBox').split(' ').map(Number);
1427
+ const currentHeight = viewBox[3];
1428
+
1429
+ // Small margin below content (matching layout.py)
1430
+ const bottomMargin = 20;
1431
+ const padding = {icon_size} > 64 ? 45 : 30; // Approximate padding based on scale
1432
+ const newHeight = Math.max(currentHeight, newBottom + bottomMargin);
1433
+
1434
+ // Only expand if needed
1435
+ if (newHeight <= currentHeight) return;
1436
+
1437
+ // Update SVG viewBox - this automatically resizes the container
1438
+ svg.setAttribute('viewBox', `${{viewBox[0]}} ${{viewBox[1]}} ${{viewBox[2]}} ${{newHeight}}`);
1439
+
1440
+ // Expand AWS Cloud box to fill the entire canvas (minus margin)
1441
+ const minY = parseFloat(cloudGroup.dataset.minY);
1442
+ const newMaxY = newHeight - bottomMargin;
1443
+
1444
+ cloudGroup.dataset.maxY = newMaxY;
1445
+
1446
+ // Update the AWS Cloud rect to fill the space
1447
+ const awsRect = document.querySelector('.group-aws_cloud rect');
1448
+ if (awsRect) {{
1449
+ awsRect.setAttribute('height', newMaxY - minY);
1450
+ }}
1451
+ }}
1452
+
1453
+ var _baseUpdateConnectionsFor = function(serviceId) {{
751
1454
  document.querySelectorAll('.connection').forEach(conn => {{
752
1455
  if (conn.dataset.source === serviceId || conn.dataset.target === serviceId) {{
753
1456
  updateConnection(conn);
754
1457
  }}
755
1458
  }});
756
- }}
1459
+ }};
1460
+ var updateConnectionsFor = _baseUpdateConnectionsFor;
757
1461
 
758
1462
  function updateAllConnections() {{
759
1463
  document.querySelectorAll('.connection').forEach(updateConnection);
@@ -809,50 +1513,58 @@ class HTMLRenderer:
809
1513
  if (hitareaEl) {{
810
1514
  hitareaEl.setAttribute('d', path);
811
1515
  }}
1516
+
1517
+ // Update multiplicity label position if present
1518
+ const multLabel = connEl.querySelector('.multiplicity-label');
1519
+ if (multLabel) {{
1520
+ const labelMidX = (sourcePos.x + targetPos.x) / 2 + halfSize;
1521
+ const labelMidY = (sourcePos.y + targetPos.y) / 2 + halfSize;
1522
+ multLabel.setAttribute('x', `${{labelMidX + 8}}`);
1523
+ multLabel.setAttribute('y', `${{labelMidY - 5}}`);
1524
+ }}
812
1525
  }}
813
1526
 
814
1527
  // ============ HIGHLIGHTING SYSTEM ============
815
1528
  let currentHighlight = null;
816
1529
 
817
1530
  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();
1531
+ const svg = document.getElementById('diagram-svg');
824
1532
 
825
- const serviceId = el.dataset.serviceId;
1533
+ // Use event delegation on SVG for dynamic element support
1534
+ svg.addEventListener('click', (e) => {{
1535
+ // Check if clicked on a service
1536
+ const serviceEl = e.target.closest('.service');
1537
+ if (serviceEl) {{
1538
+ if (serviceEl.classList.contains('dragging')) return;
1539
+ // Don't interfere with aggregate node popover (handled separately)
1540
+ if (serviceEl.classList.contains('aggregate-node')) return;
1541
+ e.stopPropagation();
826
1542
 
827
- // Toggle highlight
1543
+ const serviceId = serviceEl.dataset.serviceId;
828
1544
  if (currentHighlight === serviceId) {{
829
1545
  clearHighlights();
830
1546
  }} else {{
831
1547
  highlightService(serviceId);
832
1548
  }}
833
- }});
834
- }});
1549
+ return;
1550
+ }}
835
1551
 
836
- // Click on connection to highlight
837
- document.querySelectorAll('.connection').forEach(el => {{
838
- el.addEventListener('click', (e) => {{
1552
+ // Check if clicked on a connection
1553
+ const connEl = e.target.closest('.connection');
1554
+ if (connEl) {{
839
1555
  e.stopPropagation();
840
-
841
- const sourceId = el.dataset.source;
842
- const targetId = el.dataset.target;
1556
+ const sourceId = connEl.dataset.source;
1557
+ const targetId = connEl.dataset.target;
843
1558
  const connKey = `conn:${{sourceId}}->${{targetId}}`;
844
-
845
- // Toggle highlight
846
1559
  if (currentHighlight === connKey) {{
847
1560
  clearHighlights();
848
1561
  }} else {{
849
- highlightConnection(el, sourceId, targetId);
1562
+ highlightConnection(connEl, sourceId, targetId);
850
1563
  }}
851
- }});
852
- }});
1564
+ return;
1565
+ }}
853
1566
 
854
- // Click on background to clear highlights
855
- document.getElementById('diagram-svg').addEventListener('click', (e) => {{
1567
+ // Clicked on background
856
1568
  if (e.target.tagName === 'svg' || e.target.classList.contains('group-bg')) {{
857
1569
  clearHighlights();
858
1570
  }}
@@ -863,17 +1575,17 @@ class HTMLRenderer:
863
1575
  clearHighlights();
864
1576
  currentHighlight = serviceId;
865
1577
 
866
- // Find all connected services
867
- const connectedServices = new Set([serviceId]);
1578
+ // Find all connections involving this service
1579
+ const connectedServiceIds = new Set([serviceId]);
868
1580
  const connectedConnections = [];
869
1581
 
870
- document.querySelectorAll('.connection').forEach(conn => {{
871
- const source = conn.dataset.source;
872
- const target = conn.dataset.target;
1582
+ document.querySelectorAll('.connection:not(.conn-type-hidden)').forEach(conn => {{
1583
+ const srcId = conn.dataset.source;
1584
+ const tgtId = conn.dataset.target;
873
1585
 
874
- if (source === serviceId || target === serviceId) {{
875
- connectedServices.add(source);
876
- connectedServices.add(target);
1586
+ if (srcId === serviceId || tgtId === serviceId) {{
1587
+ connectedServiceIds.add(srcId);
1588
+ connectedServiceIds.add(tgtId);
877
1589
  connectedConnections.push(conn);
878
1590
  }}
879
1591
  }});
@@ -887,22 +1599,22 @@ class HTMLRenderer:
887
1599
  }});
888
1600
 
889
1601
  // Highlight connected services
890
- connectedServices.forEach(id => {{
891
- const el = document.querySelector(`[data-service-id="${{id}}"]`);
892
- if (el) {{
1602
+ document.querySelectorAll('.service').forEach(el => {{
1603
+ const elId = el.dataset.serviceId;
1604
+ if (connectedServiceIds.has(elId)) {{
893
1605
  el.classList.remove('dimmed');
894
1606
  el.classList.add('highlighted');
895
1607
  }}
896
1608
  }});
897
1609
 
898
- // Highlight connected connections
1610
+ // Highlight connections
899
1611
  connectedConnections.forEach(conn => {{
900
1612
  conn.classList.remove('dimmed');
901
1613
  conn.classList.add('highlighted');
902
1614
  }});
903
1615
 
904
1616
  // Show info tooltip
905
- showHighlightInfo(serviceId, connectedServices.size - 1, connectedConnections.length);
1617
+ showHighlightInfo(serviceId, connectedServiceIds.size - 1, connectedConnections.length);
906
1618
  }}
907
1619
 
908
1620
  function highlightConnection(connEl, sourceId, targetId) {{
@@ -985,28 +1697,41 @@ class HTMLRenderer:
985
1697
 
986
1698
  function initTooltips() {{
987
1699
  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;
1700
+ const svg = document.getElementById('diagram-svg');
1701
+ let tooltipTarget = null;
1702
+
1703
+ // Use event delegation on SVG for tooltip support on dynamic elements
1704
+ svg.addEventListener('mouseover', (e) => {{
1705
+ const service = e.target.closest('.service');
1706
+ if (service && service !== tooltipTarget) {{
1707
+ tooltipTarget = service;
1708
+ if (service.classList.contains('dragging')) return;
1709
+ const data = service.dataset.tooltip;
993
1710
  if (data) {{
994
1711
  tooltip.textContent = data;
995
1712
  tooltip.style.display = 'block';
996
1713
  }}
997
- }});
998
- el.addEventListener('mousemove', (e) => {{
999
- if (el.classList.contains('dragging')) return;
1714
+ }}
1715
+ }});
1716
+ svg.addEventListener('mousemove', (e) => {{
1717
+ if (tooltipTarget && !tooltipTarget.classList.contains('dragging')) {{
1000
1718
  tooltip.style.left = e.clientX + 15 + 'px';
1001
1719
  tooltip.style.top = e.clientY + 15 + 'px';
1002
- }});
1003
- el.addEventListener('mouseleave', () => {{
1720
+ }}
1721
+ }});
1722
+ svg.addEventListener('mouseout', (e) => {{
1723
+ const service = e.target.closest('.service');
1724
+ if (service && service === tooltipTarget) {{
1725
+ // Check if we're leaving to a child element (not really leaving)
1726
+ const related = e.relatedTarget;
1727
+ if (related && service.contains(related)) return;
1728
+ tooltipTarget = null;
1004
1729
  tooltip.style.display = 'none';
1005
- }});
1730
+ }}
1006
1731
  }});
1007
1732
  }}
1008
1733
 
1009
- function resetPositions() {{
1734
+ var resetPositions = function() {{
1010
1735
  Object.keys(originalPositions).forEach(id => {{
1011
1736
  servicePositions[id] = {{ ...originalPositions[id] }};
1012
1737
  const el = document.querySelector(`[data-service-id="${{id}}"]`);
@@ -1015,15 +1740,15 @@ class HTMLRenderer:
1015
1740
  }}
1016
1741
  }});
1017
1742
  updateAllConnections();
1018
- }}
1743
+ }};
1019
1744
 
1020
- function savePositions() {{
1745
+ var savePositions = function() {{
1021
1746
  const data = JSON.stringify(servicePositions);
1022
1747
  localStorage.setItem('diagramPositions', data);
1023
1748
  alert('Layout saved to browser storage!');
1024
- }}
1749
+ }};
1025
1750
 
1026
- function loadPositions() {{
1751
+ var loadPositions = function() {{
1027
1752
  const data = localStorage.getItem('diagramPositions');
1028
1753
  if (!data) {{
1029
1754
  alert('No saved layout found.');
@@ -1042,50 +1767,67 @@ class HTMLRenderer:
1042
1767
  }});
1043
1768
  updateAllConnections();
1044
1769
  alert('Layout loaded!');
1045
- }}
1770
+ }};
1046
1771
 
1047
1772
  function exportAs(format) {{
1048
1773
  const svg = document.getElementById('diagram-svg');
1049
1774
  const canvas = document.getElementById('export-canvas');
1050
1775
  const ctx = canvas.getContext('2d');
1051
1776
 
1052
- // Set canvas size
1053
- const svgRect = svg.getBoundingClientRect();
1777
+ const vbW = svg.viewBox.baseVal.width;
1778
+ const vbH = svg.viewBox.baseVal.height;
1054
1779
  const scale = 2; // Higher resolution
1055
- canvas.width = svg.viewBox.baseVal.width * scale;
1056
- canvas.height = svg.viewBox.baseVal.height * scale;
1780
+ canvas.width = vbW * scale;
1781
+ canvas.height = vbH * scale;
1782
+
1783
+ // Clone SVG so we can modify attributes for standalone rendering
1784
+ const svgClone = svg.cloneNode(true);
1785
+ // Set explicit pixel dimensions (width="100%" won't resolve in a blob image)
1786
+ svgClone.setAttribute('width', vbW);
1787
+ svgClone.setAttribute('height', vbH);
1788
+ svgClone.removeAttribute('style');
1789
+
1790
+ // Embed essential CSS inside the SVG for standalone rendering
1791
+ const styleEl = document.createElementNS('http://www.w3.org/2000/svg', 'style');
1792
+ styleEl.textContent = `
1793
+ .agg-hidden {{ display: none !important; }}
1794
+ .conn-type-hidden {{ display: none !important; }}
1795
+ `;
1796
+ svgClone.insertBefore(styleEl, svgClone.firstChild);
1057
1797
 
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);
1798
+ // Serialize and encode as data URI (avoids canvas taint from blob URLs)
1799
+ const svgData = new XMLSerializer().serializeToString(svgClone);
1800
+ const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
1801
+ const dataUri = 'data:image/svg+xml;base64,' + svgBase64;
1062
1802
 
1063
1803
  const img = new Image();
1064
1804
  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
- }}
1805
+ ctx.fillStyle = 'white';
1806
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1070
1807
 
1071
1808
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
1072
- URL.revokeObjectURL(url);
1073
1809
 
1074
- const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
1075
- const quality = format === 'jpg' ? 0.95 : undefined;
1076
- const dataUrl = canvas.toDataURL(mimeType, quality);
1810
+ try {{
1811
+ const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
1812
+ const quality = format === 'jpg' ? 0.95 : undefined;
1813
+ const dataUrl = canvas.toDataURL(mimeType, quality);
1077
1814
 
1078
- // Show modal with preview
1079
- const preview = document.getElementById('export-preview');
1080
- const download = document.getElementById('export-download');
1815
+ const preview = document.getElementById('export-preview');
1816
+ const download = document.getElementById('export-download');
1081
1817
 
1082
- preview.src = dataUrl;
1083
- download.href = dataUrl;
1084
- download.download = `aws-diagram.${{format}}`;
1818
+ preview.src = dataUrl;
1819
+ download.href = dataUrl;
1820
+ download.download = `aws-diagram.${{format}}`;
1085
1821
 
1086
- document.getElementById('export-modal').classList.add('active');
1822
+ document.getElementById('export-modal').classList.add('active');
1823
+ }} catch (err) {{
1824
+ alert('Export failed: ' + err.message);
1825
+ }}
1826
+ }};
1827
+ img.onerror = () => {{
1828
+ alert('Failed to render SVG for export.');
1087
1829
  }};
1088
- img.src = url;
1830
+ img.src = dataUri;
1089
1831
  }}
1090
1832
 
1091
1833
  function closeExportModal() {{
@@ -1098,9 +1840,950 @@ class HTMLRenderer:
1098
1840
  closeExportModal();
1099
1841
  }}
1100
1842
  }});
1843
+
1844
+ // ============ AGGREGATION SYSTEM ============
1845
+ const aggregationState = {{}}; // {{ serviceType: bool }} true=aggregated
1846
+ const originalConnections = []; // snapshot of all original connections
1847
+ const aggregateNodes = {{}}; // {{ serviceType: SVGGElement }}
1848
+ const aggregateConnections = {{}}; // {{ serviceType: [SVGGElement...] }}
1849
+ let activePopover = null;
1850
+ let selectedPopoverResource = null;
1851
+
1852
+ // Connection type filter state: {{ connType: bool }} true=visible
1853
+ const CONNECTION_TYPES = [
1854
+ {{ id: 'data_flow', label: 'Data Flow', color: '#3B48CC' }},
1855
+ {{ id: 'trigger', label: 'Event Trigger', color: '#E7157B' }},
1856
+ {{ id: 'encrypt', label: 'Encryption', color: '#6c757d' }},
1857
+ {{ id: 'network_flow', label: 'Network Flow', color: '#0d7c3f' }},
1858
+ {{ id: 'security_rule', label: 'Security Rule', color: '#d97706' }},
1859
+ {{ id: 'default', label: 'Reference', color: '#999999' }}
1860
+ ];
1861
+ const connTypeFilterState = {{}};
1862
+
1863
+ function initAggregation() {{
1864
+ if (!AGGREGATION_CONFIG || !AGGREGATION_CONFIG.groups) return;
1865
+
1866
+ // Check if any group qualifies for aggregation
1867
+ const qualifyingGroups = Object.entries(AGGREGATION_CONFIG.groups)
1868
+ .filter(([_, g]) => g.count >= AGGREGATION_CONFIG.threshold);
1869
+ if (qualifyingGroups.length === 0) return;
1870
+
1871
+ // Snapshot all original connections from DOM
1872
+ snapshotConnections();
1873
+
1874
+ // Load saved state from localStorage or use defaults
1875
+ const savedState = localStorage.getItem('diagramAggregationState');
1876
+ let loaded = null;
1877
+ if (savedState) {{
1878
+ try {{ loaded = JSON.parse(savedState); }} catch(e) {{}}
1879
+ }}
1880
+
1881
+ for (const [stype, group] of Object.entries(AGGREGATION_CONFIG.groups)) {{
1882
+ if (group.count >= AGGREGATION_CONFIG.threshold) {{
1883
+ aggregationState[stype] = loaded ? !!loaded[stype] : group.defaultAggregated;
1884
+ }}
1885
+ }}
1886
+
1887
+ // Render chip panel
1888
+ renderChipPanel();
1889
+
1890
+ // Apply initial aggregation (skip per-group connection recalc)
1891
+ for (const [stype, isAgg] of Object.entries(aggregationState)) {{
1892
+ if (isAgg) {{
1893
+ aggregateGroup(stype, true);
1894
+ }}
1895
+ }}
1896
+
1897
+ // Recalculate all connections once, considering all aggregated groups
1898
+ recalculateAllAggregateConnections();
1899
+ }}
1900
+
1901
+ function snapshotConnections() {{
1902
+ originalConnections.length = 0;
1903
+ document.querySelectorAll('.connection').forEach(conn => {{
1904
+ originalConnections.push({{
1905
+ element: conn,
1906
+ sourceId: conn.dataset.source,
1907
+ targetId: conn.dataset.target,
1908
+ sourceType: conn.dataset.sourceType || '',
1909
+ targetType: conn.dataset.targetType || '',
1910
+ label: conn.dataset.label || '',
1911
+ connType: conn.dataset.connType || 'default',
1912
+ }});
1913
+ }});
1914
+ }}
1915
+
1916
+ function getServiceNodesForType(serviceType) {{
1917
+ return Array.from(document.querySelectorAll(`.service[data-service-type="${{serviceType}}"]`))
1918
+ .filter(el => !el.classList.contains('aggregate-node'));
1919
+ }}
1920
+
1921
+ function computeCentroid(serviceType) {{
1922
+ const group = AGGREGATION_CONFIG.groups[serviceType];
1923
+ if (!group) return {{ x: 0, y: 0 }};
1924
+ let sumX = 0, sumY = 0, count = 0;
1925
+ for (const sid of group.serviceIds) {{
1926
+ const pos = servicePositions[sid];
1927
+ if (pos) {{
1928
+ sumX += pos.x;
1929
+ sumY += pos.y;
1930
+ count++;
1931
+ }}
1932
+ }}
1933
+ if (count === 0) return {{ x: 100, y: 100 }};
1934
+ return {{ x: sumX / count, y: sumY / count }};
1935
+ }}
1936
+
1937
+ function aggregateGroup(serviceType, skipConnectionRecalc) {{
1938
+ const group = AGGREGATION_CONFIG.groups[serviceType];
1939
+ if (!group) return;
1940
+
1941
+ // Hide individual nodes
1942
+ for (const sid of group.serviceIds) {{
1943
+ const el = document.querySelector(`[data-service-id="${{sid}}"]`);
1944
+ if (el) el.classList.add('agg-hidden');
1945
+ }}
1946
+
1947
+ // Calculate centroid
1948
+ const centroid = computeCentroid(serviceType);
1949
+
1950
+ // Create aggregate node in SVG
1951
+ const svg = document.getElementById('diagram-svg');
1952
+ const servicesLayer = document.getElementById('services-layer');
1953
+
1954
+ const aggG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1955
+ aggG.classList.add('service', 'draggable', 'aggregate-node');
1956
+ aggG.dataset.serviceId = `__agg_${{serviceType}}`;
1957
+ aggG.dataset.serviceType = serviceType;
1958
+ aggG.dataset.tooltip = `${{group.label}} (${{group.count}} resources - click to inspect)`;
1959
+ // Inherit VPC status from the first service in the group
1960
+ const firstNode = document.querySelector(`[data-service-id="${{group.serviceIds[0]}}"]`);
1961
+ aggG.dataset.isVpc = (firstNode && firstNode.dataset.isVpc === 'true') ? 'true' : 'false';
1962
+ aggG.setAttribute('transform', `translate(${{centroid.x}}, ${{centroid.y}})`);
1963
+ aggG.style.cursor = 'pointer';
1964
+
1965
+ // Background rect (dashed border)
1966
+ const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1967
+ bgRect.classList.add('service-bg');
1968
+ bgRect.setAttribute('x', '-8');
1969
+ bgRect.setAttribute('y', '-8');
1970
+ bgRect.setAttribute('width', `${{iconSize + 16}}`);
1971
+ bgRect.setAttribute('height', `${{iconSize + 36}}`);
1972
+ bgRect.setAttribute('fill', 'white');
1973
+ bgRect.setAttribute('stroke', group.color || '#999');
1974
+ bgRect.setAttribute('stroke-width', '2');
1975
+ bgRect.setAttribute('stroke-dasharray', '6,3');
1976
+ bgRect.setAttribute('rx', '8');
1977
+ bgRect.setAttribute('ry', '8');
1978
+ bgRect.setAttribute('filter', 'url(#shadow)');
1979
+ aggG.appendChild(bgRect);
1980
+
1981
+ // Icon
1982
+ if (group.iconHtml) {{
1983
+ const foreignObj = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
1984
+ foreignObj.setAttribute('x', '0');
1985
+ foreignObj.setAttribute('y', '0');
1986
+ foreignObj.setAttribute('width', `${{iconSize}}`);
1987
+ foreignObj.setAttribute('height', `${{iconSize}}`);
1988
+ const div = document.createElement('div');
1989
+ div.innerHTML = group.iconHtml;
1990
+ div.style.width = `${{iconSize}}px`;
1991
+ div.style.height = `${{iconSize}}px`;
1992
+ const innerSvg = div.querySelector('svg');
1993
+ if (innerSvg) {{
1994
+ innerSvg.setAttribute('width', `${{iconSize}}`);
1995
+ innerSvg.setAttribute('height', `${{iconSize}}`);
1996
+ }}
1997
+ foreignObj.appendChild(div);
1998
+ aggG.appendChild(foreignObj);
1999
+ }}
2000
+
2001
+ // Label
2002
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2003
+ label.classList.add('service-label');
2004
+ label.setAttribute('x', `${{iconSize / 2}}`);
2005
+ label.setAttribute('y', `${{iconSize + 16}}`);
2006
+ label.setAttribute('font-family', 'Arial, sans-serif');
2007
+ label.setAttribute('font-size', '12');
2008
+ label.setAttribute('fill', '#333');
2009
+ label.setAttribute('text-anchor', 'middle');
2010
+ label.setAttribute('font-weight', '500');
2011
+ label.textContent = `${{group.label}} (${{group.count}})`;
2012
+ aggG.appendChild(label);
2013
+
2014
+ // Count badge
2015
+ const badgeCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
2016
+ badgeCircle.classList.add('aggregate-badge');
2017
+ badgeCircle.setAttribute('cx', `${{iconSize + 8 - 8}}`);
2018
+ badgeCircle.setAttribute('cy', '8');
2019
+ badgeCircle.setAttribute('r', '12');
2020
+ badgeCircle.setAttribute('fill', group.color || '#ff9900');
2021
+ badgeCircle.setAttribute('stroke', 'white');
2022
+ badgeCircle.setAttribute('stroke-width', '2');
2023
+ aggG.appendChild(badgeCircle);
2024
+
2025
+ const badgeText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2026
+ badgeText.classList.add('aggregate-badge');
2027
+ badgeText.setAttribute('x', `${{iconSize + 8 - 8}}`);
2028
+ badgeText.setAttribute('y', '12');
2029
+ badgeText.setAttribute('font-family', 'Arial, sans-serif');
2030
+ badgeText.setAttribute('font-size', '11');
2031
+ badgeText.setAttribute('fill', 'white');
2032
+ badgeText.setAttribute('text-anchor', 'middle');
2033
+ badgeText.setAttribute('font-weight', 'bold');
2034
+ badgeText.textContent = group.count;
2035
+ aggG.appendChild(badgeText);
2036
+
2037
+ servicesLayer.appendChild(aggG);
2038
+ aggregateNodes[serviceType] = aggG;
2039
+
2040
+ // Register position
2041
+ servicePositions[`__agg_${{serviceType}}`] = {{ x: centroid.x, y: centroid.y }};
2042
+
2043
+ // Setup drag for aggregate node
2044
+ aggG.addEventListener('mousedown', (e) => {{
2045
+ // Drag is handled by the existing drag system since we add .draggable class
2046
+ // But we also need click for popover — distinguish via a moved flag
2047
+ }});
2048
+
2049
+ // Setup click for popover (using mouseup without movement)
2050
+ let aggDragStartPos = null;
2051
+ aggG.addEventListener('mousedown', (e) => {{
2052
+ aggDragStartPos = {{ x: e.clientX, y: e.clientY }};
2053
+ }});
2054
+ aggG.addEventListener('mouseup', (e) => {{
2055
+ if (aggDragStartPos) {{
2056
+ const dx = Math.abs(e.clientX - aggDragStartPos.x);
2057
+ const dy = Math.abs(e.clientY - aggDragStartPos.y);
2058
+ if (dx < 5 && dy < 5) {{
2059
+ // This was a click, not a drag — show popover
2060
+ // Do NOT stopPropagation: SVG mouseup must fire to clear drag state
2061
+ showAggregatePopover(serviceType, e.clientX, e.clientY);
2062
+ }}
2063
+ }}
2064
+ aggDragStartPos = null;
2065
+ }});
2066
+
2067
+ // Re-route all connections (considers all aggregated groups)
2068
+ if (!skipConnectionRecalc) {{
2069
+ recalculateAllAggregateConnections();
2070
+ }}
2071
+ }}
2072
+
2073
+ function deaggregateGroup(serviceType) {{
2074
+ const group = AGGREGATION_CONFIG.groups[serviceType];
2075
+ if (!group) return;
2076
+
2077
+ // Close popover if open for this group
2078
+ if (activePopover) {{
2079
+ closeAggregatePopover();
2080
+ }}
2081
+
2082
+ // Show individual nodes
2083
+ for (const sid of group.serviceIds) {{
2084
+ const el = document.querySelector(`[data-service-id="${{sid}}"]`);
2085
+ if (el) el.classList.remove('agg-hidden');
2086
+ }}
2087
+
2088
+ // Remove aggregate node
2089
+ if (aggregateNodes[serviceType]) {{
2090
+ aggregateNodes[serviceType].remove();
2091
+ delete aggregateNodes[serviceType];
2092
+ delete servicePositions[`__agg_${{serviceType}}`];
2093
+ }}
2094
+
2095
+ // Recalculate all connections (considers remaining aggregated groups)
2096
+ recalculateAllAggregateConnections();
2097
+ }}
2098
+
2099
+ // ============ CONNECTION RE-ROUTING ============
2100
+ // Single function that recalculates ALL aggregate connections
2101
+ // considering ALL currently aggregated groups at once.
2102
+ // This avoids cross-group issues where group A's aggregate connections
2103
+ // point to individual nodes of group B that are now hidden.
2104
+ function recalculateAllAggregateConnections() {{
2105
+ // 1. Remove ALL existing aggregate connections
2106
+ for (const [stype, conns] of Object.entries(aggregateConnections)) {{
2107
+ conns.forEach(c => c.remove());
2108
+ }}
2109
+ for (const k of Object.keys(aggregateConnections)) delete aggregateConnections[k];
2110
+
2111
+ // 2. Reset hidden state on ALL original connections
2112
+ for (const conn of originalConnections) {{
2113
+ conn.element.classList.remove('agg-hidden');
2114
+ }}
2115
+
2116
+ // 3. Build a map: serviceId -> aggregated group type (or null)
2117
+ const idToAggGroup = {{}};
2118
+ for (const [stype, isAgg] of Object.entries(aggregationState)) {{
2119
+ if (!isAgg) continue;
2120
+ const group = AGGREGATION_CONFIG.groups[stype];
2121
+ if (!group) continue;
2122
+ for (const sid of group.serviceIds) {{
2123
+ idToAggGroup[sid] = stype;
2124
+ }}
2125
+ }}
2126
+
2127
+ // 4. Process each original connection: hide and build merged map
2128
+ // Key for merged map: "resolvedSource|resolvedTarget|connType"
2129
+ // where resolvedSource/Target is either the original ID or __agg_<type>
2130
+ const mergedMap = {{}};
2131
+
2132
+ for (const conn of originalConnections) {{
2133
+ const srcGroup = idToAggGroup[conn.sourceId] || null;
2134
+ const tgtGroup = idToAggGroup[conn.targetId] || null;
2135
+
2136
+ if (!srcGroup && !tgtGroup) {{
2137
+ // Neither endpoint is aggregated: leave visible
2138
+ continue;
2139
+ }}
2140
+
2141
+ // At least one endpoint is aggregated: hide original
2142
+ conn.element.classList.add('agg-hidden');
2143
+
2144
+ if (srcGroup && tgtGroup && srcGroup === tgtGroup) {{
2145
+ // Both in same group: hide entirely, no aggregate connection
2146
+ continue;
2147
+ }}
2148
+
2149
+ // Resolve endpoints: use aggregate node ID if in an aggregated group
2150
+ const resolvedSource = srcGroup ? `__agg_${{srcGroup}}` : conn.sourceId;
2151
+ const resolvedTarget = tgtGroup ? `__agg_${{tgtGroup}}` : conn.targetId;
2152
+
2153
+ const key = `${{resolvedSource}}|${{resolvedTarget}}|${{conn.connType}}`;
2154
+ if (!mergedMap[key]) {{
2155
+ mergedMap[key] = {{
2156
+ sourceId: resolvedSource,
2157
+ targetId: resolvedTarget,
2158
+ connType: conn.connType,
2159
+ label: conn.label,
2160
+ count: 0,
2161
+ }};
2162
+ }}
2163
+ mergedMap[key].count++;
2164
+ }}
2165
+
2166
+ // 5. Create aggregate connections from merged map
2167
+ const connLayer = document.getElementById('connections-layer');
2168
+ const styles = {{
2169
+ 'data_flow': {{ color: '#3B48CC', dash: '', marker: 'url(#arrowhead-data)' }},
2170
+ 'trigger': {{ color: '#E7157B', dash: '', marker: 'url(#arrowhead-trigger)' }},
2171
+ 'encrypt': {{ color: '#6c757d', dash: '4,4', marker: 'url(#arrowhead)' }},
2172
+ 'network_flow': {{ color: '#0d7c3f', dash: '', marker: 'url(#arrowhead-network)' }},
2173
+ 'security_rule': {{ color: '#d97706', dash: '2,4', marker: 'url(#arrowhead-security)' }},
2174
+ 'default': {{ color: '#999999', dash: '', marker: 'url(#arrowhead)' }},
2175
+ }};
2176
+
2177
+ // Group aggregate connections by which agg group they belong to (for tracking)
2178
+ const newAggConns = {{}};
2179
+
2180
+ for (const [key, info] of Object.entries(mergedMap)) {{
2181
+ const style = styles[info.connType] || styles['default'];
2182
+
2183
+ const sourcePos = servicePositions[info.sourceId];
2184
+ const targetPos = servicePositions[info.targetId];
2185
+ if (!sourcePos || !targetPos) continue;
2186
+
2187
+ const pathD = calcConnectionPath(sourcePos, targetPos);
2188
+ const strokeWidth = info.count > 4 ? 3.5 : info.count > 2 ? 2.5 : 1.5;
2189
+
2190
+ const connG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2191
+ connG.classList.add('connection', 'aggregate-connection');
2192
+ connG.dataset.source = info.sourceId;
2193
+ connG.dataset.target = info.targetId;
2194
+ connG.dataset.sourceType = getServiceTypeById(info.sourceId) || '';
2195
+ connG.dataset.targetType = getServiceTypeById(info.targetId) || '';
2196
+ connG.dataset.connType = info.connType;
2197
+ connG.dataset.label = info.label;
2198
+ connG.dataset.multiplicity = info.count;
2199
+
2200
+ const hitarea = document.createElementNS('http://www.w3.org/2000/svg', 'path');
2201
+ hitarea.classList.add('connection-hitarea');
2202
+ hitarea.setAttribute('d', pathD);
2203
+ hitarea.setAttribute('fill', 'none');
2204
+ hitarea.setAttribute('stroke', 'transparent');
2205
+ hitarea.setAttribute('stroke-width', '15');
2206
+ connG.appendChild(hitarea);
2207
+
2208
+ const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
2209
+ pathEl.classList.add('connection-path');
2210
+ pathEl.setAttribute('d', pathD);
2211
+ pathEl.setAttribute('fill', 'none');
2212
+ pathEl.setAttribute('stroke', style.color);
2213
+ pathEl.setAttribute('stroke-width', `${{strokeWidth}}`);
2214
+ if (style.dash) pathEl.setAttribute('stroke-dasharray', style.dash);
2215
+ pathEl.setAttribute('marker-end', style.marker);
2216
+ pathEl.setAttribute('opacity', '0.7');
2217
+ connG.appendChild(pathEl);
2218
+
2219
+ if (info.count > 1) {{
2220
+ const midX = (sourcePos.x + targetPos.x) / 2 + iconSize / 2;
2221
+ const midY = (sourcePos.y + targetPos.y) / 2 + iconSize / 2;
2222
+ const multLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2223
+ multLabel.classList.add('multiplicity-label');
2224
+ multLabel.setAttribute('x', `${{midX + 8}}`);
2225
+ multLabel.setAttribute('y', `${{midY - 5}}`);
2226
+ multLabel.setAttribute('font-family', 'Arial, sans-serif');
2227
+ multLabel.setAttribute('font-size', '10');
2228
+ multLabel.setAttribute('font-weight', 'bold');
2229
+ multLabel.setAttribute('fill', style.color);
2230
+ multLabel.textContent = `x${{info.count}}`;
2231
+ connG.appendChild(multLabel);
2232
+ }}
2233
+
2234
+ connLayer.appendChild(connG);
2235
+
2236
+ // Track by involved agg groups for cleanup
2237
+ const involvedGroups = new Set();
2238
+ if (info.sourceId.startsWith('__agg_')) involvedGroups.add(info.sourceId.replace('__agg_', ''));
2239
+ if (info.targetId.startsWith('__agg_')) involvedGroups.add(info.targetId.replace('__agg_', ''));
2240
+ for (const g of involvedGroups) {{
2241
+ if (!newAggConns[g]) newAggConns[g] = [];
2242
+ newAggConns[g].push(connG);
2243
+ }}
2244
+ }}
2245
+
2246
+ // Update the global tracking object
2247
+ for (const [g, conns] of Object.entries(newAggConns)) {{
2248
+ aggregateConnections[g] = conns;
2249
+ }}
2250
+
2251
+ // Re-apply connection type filter to new aggregate connections
2252
+ applyConnTypeFilter();
2253
+ }}
2254
+
2255
+ function calcConnectionPath(sourcePos, targetPos) {{
2256
+ const halfSize = iconSize / 2;
2257
+ let sx = sourcePos.x + halfSize;
2258
+ let sy = sourcePos.y + halfSize;
2259
+ let tx = targetPos.x + halfSize;
2260
+ let ty = targetPos.y + halfSize;
2261
+
2262
+ if (Math.abs(ty - sy) > Math.abs(tx - sx)) {{
2263
+ if (ty > sy) {{
2264
+ sy = sourcePos.y + iconSize + 8;
2265
+ ty = targetPos.y - 8;
2266
+ }} else {{
2267
+ sy = sourcePos.y - 8;
2268
+ ty = targetPos.y + iconSize + 8;
2269
+ }}
2270
+ }} else {{
2271
+ if (tx > sx) {{
2272
+ sx = sourcePos.x + iconSize + 8;
2273
+ tx = targetPos.x - 8;
2274
+ }} else {{
2275
+ sx = sourcePos.x - 8;
2276
+ tx = targetPos.x + iconSize + 8;
2277
+ }}
2278
+ }}
2279
+
2280
+ const midX = (sx + tx) / 2;
2281
+ const midY = (sy + ty) / 2;
2282
+ return `M ${{sx}} ${{sy}} Q ${{midX}} ${{sy}}, ${{midX}} ${{midY}} T ${{tx}} ${{ty}}`;
2283
+ }}
2284
+
2285
+ function getServiceTypeById(serviceId) {{
2286
+ const el = document.querySelector(`[data-service-id="${{serviceId}}"]`);
2287
+ return el ? (el.dataset.serviceType || '') : '';
2288
+ }}
2289
+
2290
+ // ============ CHIP PANEL ============
2291
+ function renderChipPanel() {{
2292
+ const panel = document.getElementById('aggregation-panel');
2293
+ const chipsContainer = document.getElementById('aggregation-chips');
2294
+ if (!panel || !chipsContainer) return;
2295
+
2296
+ // Get qualifying groups sorted by count desc
2297
+ const groups = Object.entries(AGGREGATION_CONFIG.groups)
2298
+ .filter(([_, g]) => g.count >= AGGREGATION_CONFIG.threshold)
2299
+ .sort((a, b) => b[1].count - a[1].count);
2300
+
2301
+ if (groups.length === 0) return;
2302
+
2303
+ panel.style.display = 'flex';
2304
+ chipsContainer.innerHTML = '';
2305
+
2306
+ for (const [stype, group] of groups) {{
2307
+ const chip = document.createElement('div');
2308
+ chip.classList.add('aggregation-chip');
2309
+ chip.dataset.serviceType = stype;
2310
+ const isActive = !!aggregationState[stype];
2311
+ chip.classList.add(isActive ? 'active' : 'inactive');
2312
+ const color = group.color || '#666';
2313
+ chip.style.borderColor = color;
2314
+ chip.style.backgroundColor = isActive ? color : 'transparent';
2315
+ chip.style.color = isActive ? 'white' : color;
2316
+
2317
+ chip.innerHTML = `<span class="chip-check">${{isActive ? '&#10003;' : ''}}</span>${{group.label}} (${{group.count}})`;
2318
+
2319
+ chip.addEventListener('click', () => toggleAggregation(stype));
2320
+ chipsContainer.appendChild(chip);
2321
+ }}
2322
+ }}
2323
+
2324
+ function toggleAggregation(serviceType) {{
2325
+ const wasAggregated = aggregationState[serviceType];
2326
+ aggregationState[serviceType] = !wasAggregated;
2327
+
2328
+ if (aggregationState[serviceType]) {{
2329
+ aggregateGroup(serviceType);
2330
+ }} else {{
2331
+ deaggregateGroup(serviceType);
2332
+ }}
2333
+
2334
+ // Update chip visual
2335
+ renderChipPanel();
2336
+
2337
+ // Save state
2338
+ localStorage.setItem('diagramAggregationState', JSON.stringify(aggregationState));
2339
+ }}
2340
+
2341
+ // ============ CONNECTION TYPE FILTER ============
2342
+ function initConnectionTypeFilter() {{
2343
+ // Load saved state or default all visible
2344
+ const saved = localStorage.getItem('diagramConnTypeFilter');
2345
+ let loaded = null;
2346
+ if (saved) {{
2347
+ try {{ loaded = JSON.parse(saved); }} catch(e) {{}}
2348
+ }}
2349
+ for (const ct of CONNECTION_TYPES) {{
2350
+ connTypeFilterState[ct.id] = loaded ? (loaded[ct.id] !== false) : true;
2351
+ }}
2352
+ renderConnFilterPanel();
2353
+ applyConnTypeFilter();
2354
+ }}
2355
+
2356
+ function renderConnFilterPanel() {{
2357
+ const container = document.getElementById('conn-filter-chips');
2358
+ if (!container) return;
2359
+ container.innerHTML = '';
2360
+
2361
+ for (const ct of CONNECTION_TYPES) {{
2362
+ const chip = document.createElement('div');
2363
+ chip.classList.add('conn-filter-chip');
2364
+ const isActive = connTypeFilterState[ct.id] !== false;
2365
+ chip.classList.add(isActive ? 'active' : 'inactive');
2366
+ chip.style.borderColor = ct.color;
2367
+ chip.style.backgroundColor = isActive ? ct.color : 'transparent';
2368
+ chip.style.color = isActive ? 'white' : ct.color;
2369
+ chip.innerHTML = `<span class="chip-check">${{isActive ? '&#10003;' : ''}}</span>${{ct.label}}`;
2370
+ chip.addEventListener('click', () => toggleConnTypeFilter(ct.id));
2371
+ container.appendChild(chip);
2372
+ }}
2373
+ }}
2374
+
2375
+ function toggleConnTypeFilter(connType) {{
2376
+ connTypeFilterState[connType] = connTypeFilterState[connType] === false;
2377
+ applyConnTypeFilter();
2378
+ renderConnFilterPanel();
2379
+ localStorage.setItem('diagramConnTypeFilter', JSON.stringify(connTypeFilterState));
2380
+ }}
2381
+
2382
+ function applyConnTypeFilter() {{
2383
+ // Apply to original connections
2384
+ document.querySelectorAll('.connection').forEach(conn => {{
2385
+ const ct = conn.dataset.connType || 'default';
2386
+ if (connTypeFilterState[ct] === false) {{
2387
+ conn.classList.add('conn-type-hidden');
2388
+ }} else {{
2389
+ conn.classList.remove('conn-type-hidden');
2390
+ }}
2391
+ }});
2392
+ // Apply to aggregate connections
2393
+ for (const conns of Object.values(aggregateConnections)) {{
2394
+ conns.forEach(conn => {{
2395
+ const ct = conn.dataset.connType || 'default';
2396
+ if (connTypeFilterState[ct] === false) {{
2397
+ conn.classList.add('conn-type-hidden');
2398
+ }} else {{
2399
+ conn.classList.remove('conn-type-hidden');
2400
+ }}
2401
+ }});
2402
+ }}
2403
+ }}
2404
+
2405
+ // ============ POPOVER ============
2406
+ function showAggregatePopover(serviceType, clientX, clientY) {{
2407
+ closeAggregatePopover();
2408
+ clearHighlights();
2409
+
2410
+ const group = AGGREGATION_CONFIG.groups[serviceType];
2411
+ if (!group) return;
2412
+
2413
+ const popover = document.createElement('div');
2414
+ popover.classList.add('aggregate-popover');
2415
+ popover.id = 'aggregate-popover';
2416
+
2417
+ const header = document.createElement('div');
2418
+ header.classList.add('aggregate-popover-header');
2419
+ header.textContent = `${{group.label}} (${{group.count}})`;
2420
+ popover.appendChild(header);
2421
+
2422
+ group.serviceIds.forEach((sid, idx) => {{
2423
+ const item = document.createElement('div');
2424
+ item.classList.add('aggregate-popover-item');
2425
+ item.dataset.resourceId = sid;
2426
+
2427
+ const iconDiv = document.createElement('div');
2428
+ iconDiv.innerHTML = group.iconHtml || '';
2429
+ const innerSvg = iconDiv.querySelector('svg');
2430
+ if (innerSvg) {{
2431
+ innerSvg.setAttribute('width', '24');
2432
+ innerSvg.setAttribute('height', '24');
2433
+ }}
2434
+ item.appendChild(innerSvg || iconDiv);
2435
+
2436
+ const nameSpan = document.createElement('span');
2437
+ nameSpan.textContent = group.serviceNames[idx] || sid;
2438
+ item.appendChild(nameSpan);
2439
+
2440
+ item.addEventListener('click', (e) => {{
2441
+ e.stopPropagation();
2442
+ selectResourceInPopover(sid, serviceType, item);
2443
+ }});
2444
+
2445
+ popover.appendChild(item);
2446
+ }});
2447
+
2448
+ // Position popover near click
2449
+ popover.style.left = `${{clientX + 10}}px`;
2450
+ popover.style.top = `${{clientY + 10}}px`;
2451
+
2452
+ document.body.appendChild(popover);
2453
+ activePopover = popover;
2454
+
2455
+ // Adjust if off-screen
2456
+ const rect = popover.getBoundingClientRect();
2457
+ if (rect.right > window.innerWidth) {{
2458
+ popover.style.left = `${{clientX - rect.width - 10}}px`;
2459
+ }}
2460
+ if (rect.bottom > window.innerHeight) {{
2461
+ popover.style.top = `${{clientY - rect.height - 10}}px`;
2462
+ }}
2463
+
2464
+ // Close on click outside (delayed to avoid immediate close)
2465
+ setTimeout(() => {{
2466
+ document.addEventListener('click', closePopoverOnOutsideClick);
2467
+ }}, 10);
2468
+ }}
2469
+
2470
+ function closePopoverOnOutsideClick(e) {{
2471
+ if (activePopover && !activePopover.contains(e.target)) {{
2472
+ closeAggregatePopover();
2473
+ }}
2474
+ }}
2475
+
2476
+ function closeAggregatePopover() {{
2477
+ if (activePopover) {{
2478
+ activePopover.remove();
2479
+ activePopover = null;
2480
+ selectedPopoverResource = null;
2481
+ document.removeEventListener('click', closePopoverOnOutsideClick);
2482
+
2483
+ // Restore aggregate connections opacity
2484
+ document.querySelectorAll('.aggregate-connection').forEach(c => {{
2485
+ c.style.opacity = '';
2486
+ }});
2487
+ // Remove any temporary highlight connections
2488
+ document.querySelectorAll('.popover-highlight-conn').forEach(c => c.remove());
2489
+ }}
2490
+ }}
2491
+
2492
+ function selectResourceInPopover(resourceId, serviceType, itemEl) {{
2493
+ const group = AGGREGATION_CONFIG.groups[serviceType];
2494
+ if (!group) return;
2495
+ const groupIds = new Set(group.serviceIds);
2496
+
2497
+ // Toggle selection
2498
+ if (selectedPopoverResource === resourceId) {{
2499
+ // Deselect
2500
+ selectedPopoverResource = null;
2501
+ itemEl.classList.remove('selected');
2502
+ // Restore ALL aggregate connections
2503
+ for (const conns of Object.values(aggregateConnections)) {{
2504
+ conns.forEach(c => c.style.opacity = '');
2505
+ }}
2506
+ document.querySelectorAll('.popover-highlight-conn').forEach(c => c.remove());
2507
+ return;
2508
+ }}
2509
+
2510
+ // Clear previous selection
2511
+ if (activePopover) {{
2512
+ activePopover.querySelectorAll('.aggregate-popover-item').forEach(i => i.classList.remove('selected'));
2513
+ }}
2514
+ selectedPopoverResource = resourceId;
2515
+ itemEl.classList.add('selected');
2516
+
2517
+ // Dim ALL aggregate connections (not just this group's)
2518
+ for (const conns of Object.values(aggregateConnections)) {{
2519
+ conns.forEach(c => c.style.opacity = '0.15');
2520
+ }}
2521
+
2522
+ // Remove previous highlight connections
2523
+ document.querySelectorAll('.popover-highlight-conn').forEach(c => c.remove());
2524
+
2525
+ // Find original connections for this specific resource and draw them from aggregate node
2526
+ const aggNodeId = `__agg_${{serviceType}}`;
2527
+ const aggPos = servicePositions[aggNodeId];
2528
+ if (!aggPos) return;
2529
+
2530
+ const connLayer = document.getElementById('connections-layer');
2531
+ const styles = {{
2532
+ 'data_flow': {{ color: '#3B48CC', dash: '', marker: 'url(#arrowhead-data)' }},
2533
+ 'trigger': {{ color: '#E7157B', dash: '', marker: 'url(#arrowhead-trigger)' }},
2534
+ 'encrypt': {{ color: '#6c757d', dash: '4,4', marker: 'url(#arrowhead)' }},
2535
+ 'network_flow': {{ color: '#0d7c3f', dash: '', marker: 'url(#arrowhead-network)' }},
2536
+ 'security_rule': {{ color: '#d97706', dash: '2,4', marker: 'url(#arrowhead-security)' }},
2537
+ 'default': {{ color: '#999999', dash: '', marker: 'url(#arrowhead)' }},
2538
+ }};
2539
+
2540
+ // Build map of which IDs are in aggregated groups (for resolving targets)
2541
+ const idToAggGroup = {{}};
2542
+ for (const [stype, isAgg] of Object.entries(aggregationState)) {{
2543
+ if (!isAgg || stype === serviceType) continue;
2544
+ const g = AGGREGATION_CONFIG.groups[stype];
2545
+ if (!g) continue;
2546
+ for (const sid of g.serviceIds) {{
2547
+ idToAggGroup[sid] = stype;
2548
+ }}
2549
+ }}
2550
+
2551
+ // Collect and deduplicate highlight connections
2552
+ const hlMerged = {{}};
2553
+ for (const conn of originalConnections) {{
2554
+ let externalId = null;
2555
+ let direction = null;
2556
+
2557
+ if (conn.sourceId === resourceId && !groupIds.has(conn.targetId)) {{
2558
+ externalId = conn.targetId;
2559
+ direction = 'out';
2560
+ }} else if (conn.targetId === resourceId && !groupIds.has(conn.sourceId)) {{
2561
+ externalId = conn.sourceId;
2562
+ direction = 'in';
2563
+ }}
2564
+
2565
+ if (!externalId) continue;
2566
+
2567
+ // Resolve external ID to aggregate node if the target is in another aggregated group
2568
+ const resolvedId = idToAggGroup[externalId] ? `__agg_${{idToAggGroup[externalId]}}` : externalId;
2569
+ const hlSource = direction === 'out' ? aggNodeId : resolvedId;
2570
+ const hlTarget = direction === 'out' ? resolvedId : aggNodeId;
2571
+ const key = `${{hlSource}}|${{hlTarget}}|${{conn.connType || 'default'}}`;
2572
+
2573
+ if (!hlMerged[key]) {{
2574
+ hlMerged[key] = {{ source: hlSource, target: hlTarget, connType: conn.connType || 'default', count: 0 }};
2575
+ }}
2576
+ hlMerged[key].count++;
2577
+ }}
2578
+
2579
+ // Draw deduplicated highlight connections
2580
+ for (const [key, info] of Object.entries(hlMerged)) {{
2581
+ const sourcePos = servicePositions[info.source];
2582
+ const targetPos = servicePositions[info.target];
2583
+ if (!sourcePos || !targetPos) continue;
2584
+
2585
+ const pathD = calcConnectionPath(sourcePos, targetPos);
2586
+ const style = styles[info.connType] || styles['default'];
2587
+ const strokeWidth = info.count > 4 ? 3.5 : info.count > 2 ? 2.5 : 2;
2588
+
2589
+ const connG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
2590
+ connG.classList.add('connection', 'popover-highlight-conn');
2591
+ connG.dataset.connType = info.connType;
2592
+ if (connTypeFilterState[info.connType] === false) {{
2593
+ connG.classList.add('conn-type-hidden');
2594
+ }}
2595
+ connG.dataset.source = info.source;
2596
+ connG.dataset.target = info.target;
2597
+
2598
+ const hitarea = document.createElementNS('http://www.w3.org/2000/svg', 'path');
2599
+ hitarea.classList.add('connection-hitarea');
2600
+ hitarea.setAttribute('d', pathD);
2601
+ hitarea.setAttribute('fill', 'none');
2602
+ hitarea.setAttribute('stroke', 'transparent');
2603
+ hitarea.setAttribute('stroke-width', '15');
2604
+ connG.appendChild(hitarea);
2605
+
2606
+ const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
2607
+ pathEl.classList.add('connection-path');
2608
+ pathEl.setAttribute('d', pathD);
2609
+ pathEl.setAttribute('fill', 'none');
2610
+ pathEl.setAttribute('stroke', style.color);
2611
+ pathEl.setAttribute('stroke-width', `${{strokeWidth}}`);
2612
+ if (style.dash) pathEl.setAttribute('stroke-dasharray', style.dash);
2613
+ pathEl.setAttribute('marker-end', style.marker);
2614
+ pathEl.setAttribute('opacity', '1');
2615
+ connG.appendChild(pathEl);
2616
+
2617
+ if (info.count > 1) {{
2618
+ const midX = (sourcePos.x + targetPos.x) / 2 + iconSize / 2;
2619
+ const midY = (sourcePos.y + targetPos.y) / 2 + iconSize / 2;
2620
+ const multLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
2621
+ multLabel.classList.add('multiplicity-label');
2622
+ multLabel.setAttribute('x', `${{midX + 8}}`);
2623
+ multLabel.setAttribute('y', `${{midY - 5}}`);
2624
+ multLabel.setAttribute('font-family', 'Arial, sans-serif');
2625
+ multLabel.setAttribute('font-size', '10');
2626
+ multLabel.setAttribute('font-weight', 'bold');
2627
+ multLabel.setAttribute('fill', style.color);
2628
+ multLabel.textContent = `x${{info.count}}`;
2629
+ connG.appendChild(multLabel);
2630
+ }}
2631
+
2632
+ connLayer.appendChild(connG);
2633
+ }}
2634
+ }}
2635
+
2636
+ // ============ AGGREGATION-AWARE DRAG ============
2637
+ // Override updateConnectionsFor to also handle aggregate connections
2638
+ updateConnectionsFor = function(serviceId) {{
2639
+ // Update original connections
2640
+ _baseUpdateConnectionsFor(serviceId);
2641
+
2642
+ // Update aggregate connections involving this serviceId
2643
+ document.querySelectorAll(`.aggregate-connection[data-source="${{serviceId}}"], .aggregate-connection[data-target="${{serviceId}}"]`).forEach(conn => {{
2644
+ updateConnection(conn);
2645
+ }});
2646
+
2647
+ // Also update popover highlight connections
2648
+ document.querySelectorAll(`.popover-highlight-conn`).forEach(conn => {{
2649
+ const sId = conn.dataset.source;
2650
+ const tId = conn.dataset.target;
2651
+ if (sId === serviceId || tId === serviceId) {{
2652
+ const sPos = servicePositions[sId];
2653
+ const tPos = servicePositions[tId];
2654
+ if (sPos && tPos) {{
2655
+ const pathD = calcConnectionPath(sPos, tPos);
2656
+ const pathEl = conn.querySelector('.connection-path');
2657
+ const hitEl = conn.querySelector('.connection-hitarea');
2658
+ if (pathEl) pathEl.setAttribute('d', pathD);
2659
+ if (hitEl) hitEl.setAttribute('d', pathD);
2660
+ // Update multiplicity label position
2661
+ const multLabel = conn.querySelector('.multiplicity-label');
2662
+ if (multLabel) {{
2663
+ const midX = (sPos.x + tPos.x) / 2 + iconSize / 2;
2664
+ const midY = (sPos.y + tPos.y) / 2 + iconSize / 2;
2665
+ multLabel.setAttribute('x', `${{midX + 8}}`);
2666
+ multLabel.setAttribute('y', `${{midY - 5}}`);
2667
+ }}
2668
+ }}
2669
+ }}
2670
+ }});
2671
+ }};
2672
+
2673
+ // ============ PERSISTENCE INTEGRATION ============
2674
+ // Override save/load/reset to include aggregation state
2675
+
2676
+ savePositions = function() {{
2677
+ // Save positions (including aggregate node positions)
2678
+ const data = JSON.stringify(servicePositions);
2679
+ localStorage.setItem('diagramPositions', data);
2680
+ // Save aggregation state
2681
+ localStorage.setItem('diagramAggregationState', JSON.stringify(aggregationState));
2682
+ // Save connection type filter state
2683
+ localStorage.setItem('diagramConnTypeFilter', JSON.stringify(connTypeFilterState));
2684
+ alert('Layout and aggregation state saved!');
2685
+ }};
2686
+
2687
+ loadPositions = function() {{
2688
+ const data = localStorage.getItem('diagramPositions');
2689
+ if (!data) {{
2690
+ alert('No saved layout found.');
2691
+ return;
2692
+ }}
2693
+
2694
+ const saved = JSON.parse(data);
2695
+ Object.keys(saved).forEach(id => {{
2696
+ if (servicePositions[id] !== undefined) {{
2697
+ servicePositions[id] = saved[id];
2698
+ const el = document.querySelector(`[data-service-id="${{id}}"]`);
2699
+ if (el) {{
2700
+ el.setAttribute('transform', `translate(${{saved[id].x}}, ${{saved[id].y}})`);
2701
+ }}
2702
+ }}
2703
+ }});
2704
+
2705
+ // Load aggregation state
2706
+ const aggData = localStorage.getItem('diagramAggregationState');
2707
+ if (aggData) {{
2708
+ try {{
2709
+ const savedAgg = JSON.parse(aggData);
2710
+ for (const [stype, isAgg] of Object.entries(savedAgg)) {{
2711
+ if (aggregationState[stype] !== undefined && aggregationState[stype] !== isAgg) {{
2712
+ toggleAggregation(stype);
2713
+ }}
2714
+ }}
2715
+ }} catch(e) {{}}
2716
+ }}
2717
+
2718
+ // Load connection type filter state
2719
+ const ctData = localStorage.getItem('diagramConnTypeFilter');
2720
+ if (ctData) {{
2721
+ try {{
2722
+ const savedCt = JSON.parse(ctData);
2723
+ for (const ct of CONNECTION_TYPES) {{
2724
+ if (savedCt[ct.id] !== undefined) {{
2725
+ connTypeFilterState[ct.id] = savedCt[ct.id];
2726
+ }}
2727
+ }}
2728
+ renderConnFilterPanel();
2729
+ applyConnTypeFilter();
2730
+ }} catch(e) {{}}
2731
+ }}
2732
+
2733
+ updateAllConnections();
2734
+ alert('Layout loaded!');
2735
+ }};
2736
+
2737
+ resetPositions = function() {{
2738
+ // Reset individual node positions
2739
+ Object.keys(originalPositions).forEach(id => {{
2740
+ servicePositions[id] = {{ ...originalPositions[id] }};
2741
+ const el = document.querySelector(`[data-service-id="${{id}}"]`);
2742
+ if (el) {{
2743
+ el.setAttribute('transform', `translate(${{originalPositions[id].x}}, ${{originalPositions[id].y}})`);
2744
+ }}
2745
+ }});
2746
+
2747
+ // Reset aggregation to defaults
2748
+ for (const [stype, group] of Object.entries(AGGREGATION_CONFIG.groups)) {{
2749
+ if (group.count >= AGGREGATION_CONFIG.threshold) {{
2750
+ const shouldAgg = group.defaultAggregated;
2751
+ if (aggregationState[stype] !== shouldAgg) {{
2752
+ aggregationState[stype] = shouldAgg;
2753
+ if (shouldAgg) {{
2754
+ aggregateGroup(stype);
2755
+ }} else {{
2756
+ deaggregateGroup(stype);
2757
+ }}
2758
+ }} else if (shouldAgg && aggregateNodes[stype]) {{
2759
+ // Recalculate centroid with reset positions
2760
+ const centroid = computeCentroid(stype);
2761
+ servicePositions[`__agg_${{stype}}`] = centroid;
2762
+ aggregateNodes[stype].setAttribute('transform', `translate(${{centroid.x}}, ${{centroid.y}})`);
2763
+ }}
2764
+ }}
2765
+ }}
2766
+
2767
+ renderChipPanel();
2768
+ updateAllConnections();
2769
+ // Also update aggregate connections
2770
+ for (const [stype, conns] of Object.entries(aggregateConnections)) {{
2771
+ conns.forEach(c => updateConnection(c));
2772
+ }}
2773
+
2774
+ localStorage.removeItem('diagramAggregationState');
2775
+
2776
+ // Reset connection type filter to all visible
2777
+ for (const ct of CONNECTION_TYPES) {{
2778
+ connTypeFilterState[ct.id] = true;
2779
+ }}
2780
+ renderConnFilterPanel();
2781
+ applyConnTypeFilter();
2782
+ localStorage.removeItem('diagramConnTypeFilter');
2783
+ }};
1101
2784
  </script>
1102
2785
  </body>
1103
- </html>'''
2786
+ </html>"""
1104
2787
 
1105
2788
  def __init__(self, svg_renderer: SVGRenderer):
1106
2789
  self.svg_renderer = svg_renderer
@@ -1110,25 +2793,59 @@ class HTMLRenderer:
1110
2793
  aggregated: AggregatedResult,
1111
2794
  positions: Dict[str, Position],
1112
2795
  groups: List[ServiceGroup],
1113
- environment: str = 'dev'
2796
+ environment: str = "dev",
2797
+ actual_height: Optional[int] = None,
1114
2798
  ) -> str:
1115
2799
  """Generate complete HTML page with interactive diagram."""
1116
2800
  svg_content = self.svg_renderer.render_svg(
1117
2801
  aggregated.services,
1118
2802
  positions,
1119
2803
  aggregated.connections,
1120
- groups
2804
+ groups,
2805
+ vpc_structure=aggregated.vpc_structure,
2806
+ actual_height=actual_height,
1121
2807
  )
1122
2808
 
1123
2809
  total_resources = sum(len(s.resources) for s in aggregated.services)
1124
2810
 
2811
+ # Build aggregation config for client-side JS
2812
+ agg_metadata = ResourceAggregator.get_aggregation_metadata(aggregated)
2813
+ # Add icon SVG HTML for each service type so JS can render aggregate nodes
2814
+ agg_config: Dict[str, Any] = {"threshold": 3, "groups": {}}
2815
+ for stype, info in agg_metadata.items():
2816
+ icon_svg = self.svg_renderer.icon_mapper.get_icon_svg(
2817
+ info["icon_resource_type"], 48
2818
+ )
2819
+ icon_html = ""
2820
+ if icon_svg:
2821
+ icon_content = self.svg_renderer._extract_svg_content(icon_svg)
2822
+ icon_viewbox = self.svg_renderer._extract_svg_viewbox(icon_svg)
2823
+ if icon_content:
2824
+ icon_html = (
2825
+ f'<svg width="48" height="48" viewBox="{icon_viewbox}">'
2826
+ f"{icon_content}</svg>"
2827
+ )
2828
+ color = self.svg_renderer.icon_mapper.get_category_color(
2829
+ info["icon_resource_type"]
2830
+ )
2831
+ agg_config["groups"][stype] = {
2832
+ "count": info["count"],
2833
+ "label": info["label"],
2834
+ "defaultAggregated": info["defaultAggregated"],
2835
+ "iconHtml": icon_html,
2836
+ "color": color,
2837
+ "serviceIds": info["service_ids"],
2838
+ "serviceNames": info["service_names"],
2839
+ }
2840
+
1125
2841
  html_content = self.HTML_TEMPLATE.format(
1126
2842
  svg_content=svg_content,
1127
2843
  service_count=len(aggregated.services),
1128
2844
  resource_count=total_resources,
1129
2845
  connection_count=len(aggregated.connections),
1130
2846
  environment=environment,
1131
- icon_size=self.svg_renderer.config.icon_size
2847
+ icon_size=self.svg_renderer.config.icon_size,
2848
+ aggregation_config_json=json.dumps(agg_config),
1132
2849
  )
1133
2850
 
1134
2851
  return html_content