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.
terraformgraph/layout.py CHANGED
@@ -5,14 +5,18 @@ Computes positions for logical services in the diagram.
5
5
  """
6
6
 
7
7
  from dataclasses import dataclass, field
8
- from typing import Dict, List, Optional, Tuple
8
+ from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple
9
9
 
10
- from .aggregator import AggregatedResult, LogicalService
10
+ from .aggregator import AggregatedResult, LogicalConnection, LogicalService
11
+
12
+ if TYPE_CHECKING:
13
+ from .aggregator import AvailabilityZone, VPCStructure
11
14
 
12
15
 
13
16
  @dataclass
14
17
  class Position:
15
18
  """Position and size of an element."""
19
+
16
20
  x: float
17
21
  y: float
18
22
  width: float
@@ -22,6 +26,7 @@ class Position:
22
26
  @dataclass
23
27
  class ServiceGroup:
24
28
  """A visual group of services."""
29
+
25
30
  group_type: str # 'aws_cloud', 'vpc', 'global'
26
31
  name: str
27
32
  services: List[LogicalService] = field(default_factory=list)
@@ -30,7 +35,13 @@ class ServiceGroup:
30
35
 
31
36
  @dataclass
32
37
  class LayoutConfig:
33
- """Configuration for layout engine."""
38
+ """Configuration for layout engine.
39
+
40
+ Supports responsive sizing based on content. Base values are scaled
41
+ according to the number of services and VPC complexity.
42
+ """
43
+
44
+ # Base dimensions (will be scaled)
34
45
  canvas_width: int = 1400
35
46
  canvas_height: int = 900
36
47
  padding: int = 30
@@ -41,17 +52,86 @@ class LayoutConfig:
41
52
  row_spacing: int = 100
42
53
  column_spacing: int = 130
43
54
 
55
+ # Responsive scaling factors
56
+ min_scale: float = 0.6
57
+ max_scale: float = 1.5
58
+
59
+ def scaled(self, scale: float) -> "LayoutConfig":
60
+ """Create a new config with scaled dimensions."""
61
+ clamped_scale = max(self.min_scale, min(self.max_scale, scale))
62
+ return LayoutConfig(
63
+ canvas_width=int(self.canvas_width * clamped_scale),
64
+ canvas_height=int(self.canvas_height * clamped_scale),
65
+ padding=int(self.padding * clamped_scale),
66
+ icon_size=int(self.icon_size * clamped_scale),
67
+ icon_spacing=int(self.icon_spacing * clamped_scale),
68
+ group_padding=int(self.group_padding * clamped_scale),
69
+ label_height=int(self.label_height * clamped_scale),
70
+ row_spacing=int(self.row_spacing * clamped_scale),
71
+ column_spacing=int(self.column_spacing * clamped_scale),
72
+ min_scale=self.min_scale,
73
+ max_scale=self.max_scale,
74
+ )
75
+
44
76
 
45
77
  class LayoutEngine:
46
78
  """Computes positions for diagram elements."""
47
79
 
48
80
  def __init__(self, config: Optional[LayoutConfig] = None):
49
- self.config = config or LayoutConfig()
81
+ self.base_config = config or LayoutConfig()
82
+ self.config = self.base_config
83
+
84
+ def _compute_responsive_scale(self, aggregated: AggregatedResult) -> float:
85
+ """Compute scale factor based on content complexity.
86
+
87
+ Factors considered:
88
+ - Number of services
89
+ - Number of AZs
90
+ - Number of subnets per AZ
91
+ - Number of VPC endpoints
92
+ """
93
+ num_services = len(aggregated.services)
94
+
95
+ # Count VPC complexity
96
+ num_azs = 0
97
+ max_subnets_per_az = 0
98
+ num_endpoints = 0
99
+
100
+ if aggregated.vpc_structure:
101
+ num_azs = len(aggregated.vpc_structure.availability_zones)
102
+ for az in aggregated.vpc_structure.availability_zones:
103
+ max_subnets_per_az = max(max_subnets_per_az, len(az.subnets))
104
+ num_endpoints = (
105
+ len(aggregated.vpc_structure.endpoints) if aggregated.vpc_structure.endpoints else 0
106
+ )
107
+
108
+ # Base scale on service count (optimal for 8-12 services)
109
+ service_scale = 1.0
110
+ if num_services <= 4:
111
+ service_scale = 0.8
112
+ elif num_services <= 8:
113
+ service_scale = 0.9
114
+ elif num_services <= 15:
115
+ service_scale = 1.0
116
+ elif num_services <= 25:
117
+ service_scale = 1.2
118
+ else:
119
+ service_scale = 1.4
120
+
121
+ # Adjust for VPC complexity
122
+ vpc_scale = 1.0
123
+ if num_azs >= 3:
124
+ vpc_scale *= 1.1
125
+ if max_subnets_per_az >= 4:
126
+ vpc_scale *= 1.15
127
+ if num_endpoints >= 4:
128
+ vpc_scale *= 1.05
129
+
130
+ return service_scale * vpc_scale
50
131
 
51
132
  def compute_layout(
52
- self,
53
- aggregated: AggregatedResult
54
- ) -> Tuple[Dict[str, Position], List[ServiceGroup]]:
133
+ self, aggregated: AggregatedResult
134
+ ) -> Tuple[Dict[str, Position], List[ServiceGroup], int]:
55
135
  """
56
136
  Compute positions for all logical services.
57
137
 
@@ -59,26 +139,35 @@ class LayoutEngine:
59
139
  - Top row: Internet-facing services (CloudFront, WAF, Route53, ACM)
60
140
  - Middle: VPC box with ALB, ECS, EC2
61
141
  - Bottom rows: Global services grouped by function
142
+
143
+ Dimensions are computed responsively based on content.
144
+
145
+ Returns:
146
+ Tuple of (positions dict, groups list, actual_height)
62
147
  """
148
+ # Compute responsive scale and apply to config
149
+ scale = self._compute_responsive_scale(aggregated)
150
+ self.config = self.base_config.scaled(scale)
151
+
63
152
  positions: Dict[str, Position] = {}
64
153
  groups: List[ServiceGroup] = []
65
154
 
66
- # Create AWS Cloud container
155
+ # Placeholder for AWS Cloud - will be updated at the end
67
156
  aws_cloud = ServiceGroup(
68
- group_type='aws_cloud',
69
- name='AWS Cloud',
157
+ group_type="aws_cloud",
158
+ name="AWS Cloud",
70
159
  position=Position(
71
160
  x=self.config.padding,
72
161
  y=self.config.padding,
73
162
  width=self.config.canvas_width - 2 * self.config.padding,
74
- height=self.config.canvas_height - 2 * self.config.padding
75
- )
163
+ height=0, # Will be calculated at the end
164
+ ),
76
165
  )
77
166
  groups.append(aws_cloud)
78
167
 
79
168
  # Categorize services for layout
80
169
  edge_services = [] # CloudFront, WAF, Route53, ACM, Cognito
81
- vpc_services = [] # ALB, ECS, EC2, Security
170
+ vpc_services = [] # ALB, ECS, EC2, Security
82
171
  data_services = [] # S3, DynamoDB, MongoDB
83
172
  messaging_services = [] # SQS, SNS, EventBridge
84
173
  security_services = [] # KMS, Secrets, IAM
@@ -86,15 +175,24 @@ class LayoutEngine:
86
175
 
87
176
  for service in aggregated.services:
88
177
  st = service.service_type
89
- if st in ('cloudfront', 'waf', 'route53', 'acm', 'cognito'):
178
+ if st in ("cloudfront", "waf", "route53", "acm", "cognito"):
90
179
  edge_services.append(service)
91
- elif st in ('alb', 'ecs', 'ec2', 'security', 'vpc'):
180
+ elif st in (
181
+ "alb",
182
+ "ecs",
183
+ "ec2",
184
+ "security_groups",
185
+ "security",
186
+ "vpc",
187
+ "internet_gateway",
188
+ "nat_gateway",
189
+ ):
92
190
  vpc_services.append(service)
93
- elif st in ('s3', 'dynamodb', 'mongodb'):
191
+ elif st in ("s3", "dynamodb", "mongodb"):
94
192
  data_services.append(service)
95
- elif st in ('sqs', 'sns', 'eventbridge'):
193
+ elif st in ("sqs", "sns", "eventbridge"):
96
194
  messaging_services.append(service)
97
- elif st in ('kms', 'secrets', 'iam'):
195
+ elif st in ("kms", "secrets", "secrets_manager", "iam"):
98
196
  security_services.append(service)
99
197
  else:
100
198
  other_services.append(service)
@@ -106,90 +204,241 @@ class LayoutEngine:
106
204
  x = self._center_row_start(len(edge_services))
107
205
  for service in edge_services:
108
206
  positions[service.id] = Position(
109
- x=x, y=y_offset,
110
- width=self.config.icon_size,
111
- height=self.config.icon_size
207
+ x=x, y=y_offset, width=self.config.icon_size, height=self.config.icon_size
112
208
  )
113
209
  x += self.config.column_spacing
114
210
 
115
211
  y_offset += self.config.row_spacing + 20
116
212
 
117
- # Row 2: VPC box with internal services
118
- vpc_x = self.config.padding + 50
119
- vpc_width = self.config.canvas_width - 2 * (self.config.padding + 50)
120
- vpc_height = 180
213
+ # Track VPC bottom for non-VPC services layout
214
+ vpc_bottom_y = y_offset
121
215
 
216
+ # Row 2: VPC box with internal services (only if VPC resources exist)
122
217
  # Filter out 'vpc' itself from vpc_services for positioning
123
- vpc_internal = [s for s in vpc_services if s.service_type != 'vpc']
218
+ vpc_internal = [s for s in vpc_services if s.service_type != "vpc"]
124
219
 
125
- vpc_group = ServiceGroup(
126
- group_type='vpc',
127
- name='VPC',
128
- services=vpc_internal,
129
- position=Position(x=vpc_x, y=y_offset, width=vpc_width, height=vpc_height)
130
- )
131
- groups.append(vpc_group)
132
-
133
- # Position services inside VPC
134
- inner_y = y_offset + self.config.group_padding + 30
135
- if vpc_internal:
136
- x = self._center_row_start(len(vpc_internal), vpc_x + self.config.group_padding,
137
- vpc_x + vpc_width - self.config.group_padding)
138
- for service in vpc_internal:
139
- positions[service.id] = Position(
140
- x=x, y=inner_y,
141
- width=self.config.icon_size,
142
- height=self.config.icon_size
143
- )
144
- x += self.config.column_spacing
220
+ # Only create VPC box if there are VPC services OR vpc_structure exists
221
+ has_vpc_content = len(vpc_internal) > 0 or aggregated.vpc_structure is not None
145
222
 
146
- y_offset += vpc_height + 40
223
+ if has_vpc_content:
224
+ vpc_x = self.config.padding + 50
225
+ vpc_width = self.config.canvas_width - 2 * (self.config.padding + 50)
147
226
 
148
- # Row 3: Data services
149
- if data_services:
150
- x = self._center_row_start(len(data_services))
151
- for service in data_services:
152
- positions[service.id] = Position(
153
- x=x, y=y_offset,
154
- width=self.config.icon_size,
155
- height=self.config.icon_size
156
- )
157
- x += self.config.column_spacing
227
+ # Separate services: those with subnet_ids go inside subnets, others in top row
228
+ services_with_subnets = [s for s in vpc_internal if s.subnet_ids]
229
+ services_without_subnets = [s for s in vpc_internal if not s.subnet_ids]
158
230
 
159
- y_offset += self.config.row_spacing
231
+ # Use dynamic VPC height if vpc_structure exists
232
+ if aggregated.vpc_structure:
233
+ vpc_height = self._compute_vpc_height(
234
+ aggregated.vpc_structure,
235
+ has_vpc_services=len(services_without_subnets) > 0,
236
+ services_with_subnets=services_with_subnets,
237
+ )
238
+ else:
239
+ vpc_height = 180
160
240
 
161
- # Row 4: Messaging services
162
- if messaging_services:
163
- x = self._center_row_start(len(messaging_services))
164
- for service in messaging_services:
165
- positions[service.id] = Position(
166
- x=x, y=y_offset,
167
- width=self.config.icon_size,
168
- height=self.config.icon_size
241
+ vpc_pos = Position(x=vpc_x, y=y_offset, width=vpc_width, height=vpc_height)
242
+ vpc_group = ServiceGroup(
243
+ group_type="vpc", name="VPC", services=vpc_internal, position=vpc_pos
244
+ )
245
+ groups.append(vpc_group)
246
+
247
+ # Position services WITHOUT subnet_ids at the TOP, above AZs
248
+ services_row_y = y_offset + self.config.group_padding + 30
249
+ if services_without_subnets:
250
+ x = self._center_row_start(
251
+ len(services_without_subnets),
252
+ vpc_x + self.config.group_padding,
253
+ vpc_x + vpc_width - self.config.group_padding,
169
254
  )
170
- x += self.config.column_spacing
255
+ for service in services_without_subnets:
256
+ positions[service.id] = Position(
257
+ x=x,
258
+ y=services_row_y,
259
+ width=self.config.icon_size,
260
+ height=self.config.icon_size,
261
+ )
262
+ x += self.config.column_spacing
263
+
264
+ # Layout VPC structure (AZs and endpoints) BELOW services
265
+ if aggregated.vpc_structure:
266
+ # AZs start below the services row
267
+ az_start_y = (
268
+ services_row_y + self.config.icon_size + 50
269
+ if services_without_subnets
270
+ else services_row_y
271
+ )
272
+ self._layout_vpc_structure(
273
+ aggregated.vpc_structure,
274
+ vpc_pos,
275
+ positions,
276
+ groups,
277
+ az_start_y=az_start_y,
278
+ services_with_subnets=services_with_subnets,
279
+ )
280
+
281
+ y_offset += vpc_height + 40
282
+ vpc_bottom_y = y_offset
283
+
284
+ # Non-VPC services: use connection-based organic layout
285
+ non_vpc_services = data_services + messaging_services + security_services + other_services
286
+
287
+ if non_vpc_services:
288
+ y_offset = self._layout_by_connections(
289
+ services=non_vpc_services,
290
+ connections=aggregated.connections,
291
+ start_x=self.config.padding + 50,
292
+ start_y=vpc_bottom_y,
293
+ available_width=self.config.canvas_width - 2 * (self.config.padding + 50),
294
+ positions=positions,
295
+ )
296
+
297
+ # Calculate actual height based on final positions
298
+ max_bottom = 0
299
+ for pos in positions.values():
300
+ bottom = pos.y + pos.height + 50 # Add padding for labels
301
+ max_bottom = max(max_bottom, bottom)
302
+
303
+ # Add small bottom margin (just enough for aesthetics)
304
+ bottom_margin = 20
305
+ actual_height = int(max_bottom + bottom_margin)
171
306
 
172
- y_offset += self.config.row_spacing
307
+ # Update AWS Cloud box height to fill the canvas (with small margin)
308
+ aws_cloud.position.height = actual_height - self.config.padding - bottom_margin
309
+
310
+ return positions, groups, actual_height
311
+
312
+ def _build_connection_graph(
313
+ self, services: List[LogicalService], connections: List[LogicalConnection]
314
+ ) -> Dict[str, Set[str]]:
315
+ """Build adjacency list of connected service types.
316
+
317
+ Returns a bidirectional graph where each service_type maps to
318
+ the set of service_types it's connected to.
319
+ """
320
+ # Get service types present
321
+ present_types = {s.service_type for s in services}
322
+
323
+ # Build adjacency list (bidirectional)
324
+ graph: Dict[str, Set[str]] = {t: set() for t in present_types}
325
+
326
+ for conn in connections:
327
+ src, tgt = conn.source_id, conn.target_id
328
+ # Extract service_type from id (e.g., "lambda.Api Handler" -> "lambda")
329
+ src_type = src.split(".")[0] if "." in src else src
330
+ tgt_type = tgt.split(".")[0] if "." in tgt else tgt
331
+
332
+ if src_type in present_types and tgt_type in present_types:
333
+ graph[src_type].add(tgt_type)
334
+ graph[tgt_type].add(src_type)
335
+
336
+ return graph
337
+
338
+ def _layout_by_connections(
339
+ self,
340
+ services: List[LogicalService],
341
+ connections: List[LogicalConnection],
342
+ start_x: float,
343
+ start_y: float,
344
+ available_width: float,
345
+ positions: Dict[str, Position],
346
+ ) -> float:
347
+ """Position services based on their connections (organic layout).
348
+
349
+ Services with connections are placed adjacent to each other.
350
+ Returns the Y position after all services are placed.
351
+ """
352
+ if not services:
353
+ return start_y
354
+
355
+ # Build connection graph
356
+ graph = self._build_connection_graph(services, connections)
357
+
358
+ # Group services by type
359
+ by_type: Dict[str, List[LogicalService]] = {}
360
+ for s in services:
361
+ by_type.setdefault(s.service_type, []).append(s)
362
+
363
+ # Sort types by number of connections (most connected first)
364
+ sorted_types = sorted(by_type.keys(), key=lambda t: len(graph.get(t, set())), reverse=True)
365
+
366
+ # Calculate grid dimensions
367
+ service_width = self.config.icon_size + 50 # icon + padding
368
+ service_height = self.config.icon_size + 50 # icon + label + padding
369
+ cols = max(1, int(available_width / service_width))
370
+
371
+ # Track placed service types and their grid positions
372
+ placed_positions: Dict[str, Tuple[int, int]] = {} # type -> (row, col)
373
+ grid: Dict[Tuple[int, int], str] = {} # (row, col) -> service_type
374
+
375
+ current_row = 0
376
+ current_col = 0
377
+
378
+ for service_type in sorted_types:
379
+ type_services = by_type[service_type]
380
+
381
+ # Find best position based on connections
382
+ connected_types = graph.get(service_type, set())
383
+ best_col = current_col
384
+ best_row = current_row
385
+
386
+ # If connected to already-placed types, try to position nearby
387
+ for ct in connected_types:
388
+ if ct in placed_positions:
389
+ ct_row, ct_col = placed_positions[ct]
390
+ # Try adjacent positions (right, below, left)
391
+ candidates = [
392
+ (ct_row, ct_col + 1),
393
+ (ct_row + 1, ct_col),
394
+ (ct_row, ct_col - 1),
395
+ (ct_row + 1, ct_col + 1),
396
+ ]
397
+ for r, c in candidates:
398
+ if c >= 0 and c < cols and (r, c) not in grid:
399
+ best_row, best_col = r, c
400
+ break
401
+ break
402
+
403
+ # Ensure we don't go out of bounds
404
+ if best_col >= cols:
405
+ best_col = 0
406
+ best_row = current_row + 1
407
+
408
+ # Position all services of this type (they share the same grid cell conceptually)
409
+ for i, service in enumerate(type_services):
410
+ # Calculate actual position
411
+ col = best_col + i
412
+ row = best_row
413
+ if col >= cols:
414
+ col = col % cols
415
+ row += (best_col + i) // cols
416
+
417
+ x = start_x + col * service_width
418
+ y = start_y + row * service_height
173
419
 
174
- # Row 5: Security + Other services
175
- bottom_services = security_services + other_services
176
- if bottom_services:
177
- x = self._center_row_start(len(bottom_services))
178
- for service in bottom_services:
179
420
  positions[service.id] = Position(
180
- x=x, y=y_offset,
181
- width=self.config.icon_size,
182
- height=self.config.icon_size
421
+ x=x, y=y, width=self.config.icon_size, height=self.config.icon_size
183
422
  )
184
- x += self.config.column_spacing
185
423
 
186
- return positions, groups
424
+ grid[(row, col)] = service_type
425
+ current_row = max(current_row, row)
426
+
427
+ # Track where this type was placed (first position)
428
+ placed_positions[service_type] = (best_row, best_col)
429
+
430
+ # Update current position for next unconnected type
431
+ if best_col + len(type_services) >= cols:
432
+ current_row = best_row + 1
433
+ current_col = 0
434
+ else:
435
+ current_col = best_col + len(type_services)
436
+
437
+ # Return the Y position after all services
438
+ return start_y + (current_row + 1) * service_height + 20
187
439
 
188
440
  def _center_row_start(
189
- self,
190
- num_items: int,
191
- min_x: Optional[float] = None,
192
- max_x: Optional[float] = None
441
+ self, num_items: int, min_x: Optional[float] = None, max_x: Optional[float] = None
193
442
  ) -> float:
194
443
  """Calculate starting X position to center items in a row."""
195
444
  if min_x is None:
@@ -198,42 +447,257 @@ class LayoutEngine:
198
447
  max_x = self.config.canvas_width - self.config.padding
199
448
 
200
449
  available_width = max_x - min_x
201
- total_items_width = num_items * self.config.icon_size + (num_items - 1) * self.config.icon_spacing
450
+ total_items_width = (
451
+ num_items * self.config.icon_size + (num_items - 1) * self.config.icon_spacing
452
+ )
202
453
  return min_x + (available_width - total_items_width) / 2
203
454
 
204
- def compute_connection_path(
455
+ def _compute_vpc_height(
205
456
  self,
206
- source_pos: Position,
207
- target_pos: Position,
208
- connection_type: str = 'default'
209
- ) -> str:
210
- """Compute SVG path for a connection between two services."""
211
- # Calculate center points
212
- sx = source_pos.x + source_pos.width / 2
213
- sy = source_pos.y + source_pos.height / 2
214
- tx = target_pos.x + target_pos.width / 2
215
- ty = target_pos.y + target_pos.height / 2
216
-
217
- # Use straight lines with slight curves for cleaner look
218
- if abs(ty - sy) > abs(tx - sx):
219
- # Mostly vertical - connect top/bottom
220
- if ty > sy:
221
- sy = source_pos.y + source_pos.height
222
- ty = target_pos.y
223
- else:
224
- sy = source_pos.y
225
- ty = target_pos.y + target_pos.height
226
- else:
227
- # Mostly horizontal - connect left/right
228
- if tx > sx:
229
- sx = source_pos.x + source_pos.width
230
- tx = target_pos.x
231
- else:
232
- sx = source_pos.x
233
- tx = target_pos.x + target_pos.width
457
+ vpc_structure: "VPCStructure",
458
+ has_vpc_services: bool = True,
459
+ services_with_subnets: Optional[List[LogicalService]] = None,
460
+ ) -> int:
461
+ """Compute VPC box height based on subnet count, services, and endpoints.
462
+
463
+ Args:
464
+ vpc_structure: VPCStructure with availability zones, subnets, and endpoints
465
+ has_vpc_services: Whether there are VPC services to display in top row
466
+ services_with_subnets: List of services that go inside subnets (for precise calculation)
467
+
468
+ Returns:
469
+ Height in pixels for the VPC box
470
+ """
471
+ if not vpc_structure or not vpc_structure.availability_zones:
472
+ return 180 # Default height
473
+
474
+ # Constants - MUST use scaled values to match compute_layout()
475
+ subnet_padding = 10 # Padding between subnets
476
+ az_header_height = 30 # Height for AZ header
477
+ # VPC header uses: group_padding + 30 (see compute_layout line 239)
478
+ vpc_header_height = self.config.group_padding + 30
479
+ base_padding = 40 # Bottom padding
480
+ # Services row uses: icon_size + 50 (see compute_layout line 254)
481
+ services_row_height = (self.config.icon_size + 50) if has_vpc_services else 0
482
+ empty_subnet_height = 60
483
+ # Service subnet height uses: icon_size + 56 (see _layout_subnets line 585)
484
+ service_subnet_height = self.config.icon_size + 56
485
+
486
+ # Calculate height needed for subnets in each AZ
487
+ # Find which subnets will have services
488
+ subnets_with_services: set = set()
489
+ if services_with_subnets:
490
+ for service in services_with_subnets:
491
+ for subnet_id in service.subnet_ids:
492
+ # Normalize subnet ID
493
+ if subnet_id.startswith("_state_subnet:"):
494
+ # Will be resolved later, assume it matches a subnet
495
+ subnets_with_services.add(subnet_id)
496
+ else:
497
+ subnets_with_services.add(subnet_id)
498
+
499
+ # Calculate max height needed across all AZs
500
+ max_az_content_height = 0
501
+ for az in vpc_structure.availability_zones:
502
+ az_content_height = 0
503
+ for subnet in az.subnets:
504
+ # Check if this subnet will have services
505
+ has_services = subnet.resource_id in subnets_with_services
506
+ # Also check by AWS ID
507
+ if subnet.aws_id:
508
+ if f"_state_subnet:{subnet.aws_id}" in subnets_with_services:
509
+ has_services = True
510
+
511
+ subnet_h = service_subnet_height if has_services else empty_subnet_height
512
+ az_content_height += subnet_h + subnet_padding
513
+
514
+ max_az_content_height = max(max_az_content_height, az_content_height)
515
+
516
+ # Total height for subnets
517
+ height_for_subnets = (
518
+ vpc_header_height
519
+ + services_row_height
520
+ + az_header_height
521
+ + max_az_content_height
522
+ + base_padding
523
+ )
524
+
525
+ # Height based on endpoints (if present)
526
+ num_endpoints = len(vpc_structure.endpoints) if vpc_structure.endpoints else 0
527
+ endpoint_spacing = 72 # Match the actual spacing used in _layout_vpc_structure
528
+ height_for_endpoints = (
529
+ (
530
+ vpc_header_height
531
+ + services_row_height
532
+ + (num_endpoints * endpoint_spacing)
533
+ + base_padding
534
+ + 20
535
+ )
536
+ if num_endpoints > 0
537
+ else 0
538
+ )
539
+
540
+ return max(200, height_for_subnets, height_for_endpoints)
541
+
542
+ def _layout_vpc_structure(
543
+ self,
544
+ vpc_structure: "VPCStructure",
545
+ vpc_pos: Position,
546
+ positions: Dict[str, Position],
547
+ groups: List[ServiceGroup],
548
+ az_start_y: Optional[float] = None,
549
+ services_with_subnets: Optional[List[LogicalService]] = None,
550
+ ) -> None:
551
+ """Layout availability zones and endpoints within VPC.
552
+
553
+ Args:
554
+ vpc_structure: VPCStructure with AZs and endpoints
555
+ vpc_pos: Position of the VPC container
556
+ positions: Dict to add subnet positions to
557
+ groups: List to add AZ groups to
558
+ az_start_y: Optional Y position where AZs should start (below services)
559
+ services_with_subnets: Services that should be placed inside their subnets
560
+ """
561
+ if not vpc_structure:
562
+ return
563
+
564
+ num_azs = len(vpc_structure.availability_zones)
565
+ if num_azs == 0:
566
+ return
567
+
568
+ # Calculate AZ dimensions
569
+ az_padding = 15
570
+ endpoint_width = 90 # Space reserved for endpoints on right
571
+ available_width = vpc_pos.width - (2 * az_padding) - endpoint_width
572
+ az_width = (available_width - (num_azs - 1) * az_padding) / num_azs
573
+
574
+ # Build global mapping from AWS subnet IDs to resource IDs
575
+ # This is needed because ALBs can reference subnets from multiple AZs
576
+ aws_id_to_resource_id: Dict[str, str] = {}
577
+ for az in vpc_structure.availability_zones:
578
+ for subnet in az.subnets:
579
+ if subnet.aws_id:
580
+ aws_id_to_resource_id[subnet.aws_id] = subnet.resource_id
581
+
582
+ # Layout each AZ
583
+ az_y = az_start_y if az_start_y is not None else vpc_pos.y + 40
584
+ az_height = (vpc_pos.y + vpc_pos.height - 20) - az_y # Extend to bottom of VPC
585
+ az_x = vpc_pos.x + az_padding
586
+
587
+ for az in vpc_structure.availability_zones:
588
+ az_pos = Position(x=az_x, y=az_y, width=az_width, height=az_height)
589
+
590
+ # Create AZ group
591
+ az_group = ServiceGroup(group_type="az", name=f"AZ {az.short_name}", position=az_pos)
592
+ groups.append(az_group)
593
+
594
+ # Layout subnets inside this AZ
595
+ self._layout_subnets(
596
+ az, az_pos, positions, services_with_subnets, aws_id_to_resource_id
597
+ )
598
+
599
+ az_x += az_width + az_padding
600
+
601
+ # Layout VPC endpoints INSIDE the VPC (right column, within border)
602
+ if vpc_structure.endpoints:
603
+ # Position endpoints inside the VPC border, in the reserved space
604
+ endpoint_box_width = 80
605
+ endpoint_box_height = 65
606
+ endpoint_x = vpc_pos.x + vpc_pos.width - endpoint_width + 3 # Inside the reserved space
607
+ endpoint_y = az_y + 5 # Start at same level as AZs
608
+ endpoint_spacing = 72 # Spacing between endpoints
234
609
 
235
- # Simple curved path
236
- mid_x = (sx + tx) / 2
237
- mid_y = (sy + ty) / 2
610
+ for endpoint in vpc_structure.endpoints:
611
+ positions[endpoint.resource_id] = Position(
612
+ x=endpoint_x, y=endpoint_y, width=endpoint_box_width, height=endpoint_box_height
613
+ )
614
+ endpoint_y += endpoint_spacing
615
+
616
+ def _layout_subnets(
617
+ self,
618
+ az: "AvailabilityZone",
619
+ az_pos: Position,
620
+ positions: Dict[str, Position],
621
+ services_with_subnets: Optional[List[LogicalService]] = None,
622
+ aws_id_to_resource_id: Optional[Dict[str, str]] = None,
623
+ ) -> None:
624
+ """Layout subnets inside an availability zone.
625
+
626
+ Args:
627
+ az: AvailabilityZone containing subnets
628
+ az_pos: Position of the AZ container
629
+ positions: Dict to add subnet positions to
630
+ services_with_subnets: Services that should be placed inside their subnets
631
+ aws_id_to_resource_id: Mapping from AWS subnet IDs to resource IDs
632
+ """
633
+ if not az.subnets:
634
+ return
635
+
636
+ subnet_padding = 10
637
+ # Base subnet height: 60px for empty subnets (enough for label and visibility)
638
+ # Must match _compute_vpc_height for consistency
639
+ subnet_height = 60
640
+ subnet_width = az_pos.width - (2 * subnet_padding)
641
+
642
+ subnet_y = az_pos.y + 30 # Below AZ header
643
+
644
+ # Use provided mapping or empty dict
645
+ if aws_id_to_resource_id is None:
646
+ aws_id_to_resource_id = {}
647
+
648
+ # Build mapping: subnet_resource_id -> list of services
649
+ services_by_subnet: Dict[str, List[LogicalService]] = {}
650
+ if services_with_subnets:
651
+ for service in services_with_subnets:
652
+ for subnet_id in service.subnet_ids:
653
+ # Handle _state_subnet: prefixed IDs (from Terraform state)
654
+ if subnet_id.startswith("_state_subnet:"):
655
+ aws_id = subnet_id[len("_state_subnet:") :]
656
+ # Map AWS ID to resource ID if we have it
657
+ if aws_id in aws_id_to_resource_id:
658
+ resource_id = aws_id_to_resource_id[aws_id]
659
+ services_by_subnet.setdefault(resource_id, []).append(service)
660
+ else:
661
+ # Direct resource ID reference (e.g., aws_subnet.public)
662
+ services_by_subnet.setdefault(subnet_id, []).append(service)
663
+
664
+ for subnet in az.subnets:
665
+ # Check if any services belong to this subnet
666
+ subnet_services = services_by_subnet.get(subnet.resource_id, [])
667
+
668
+ # Increase subnet height if it contains services
669
+ # Service box needs: icon (64px) + label padding below (36px) + top padding (8px) + margins
670
+ # Total service box height: 64 + 44 = 108px, add margin = 120px
671
+ actual_subnet_height = subnet_height
672
+ if subnet_services:
673
+ actual_subnet_height = max(
674
+ subnet_height, self.config.icon_size + 56
675
+ ) # 64 + 56 = 120
676
+
677
+ positions[subnet.resource_id] = Position(
678
+ x=az_pos.x + subnet_padding,
679
+ y=subnet_y,
680
+ width=subnet_width,
681
+ height=actual_subnet_height,
682
+ )
238
683
 
239
- return f"M {sx} {sy} Q {mid_x} {sy}, {mid_x} {mid_y} Q {mid_x} {ty}, {tx} {ty}"
684
+ # Position services inside this subnet
685
+ if subnet_services:
686
+ service_x = az_pos.x + subnet_padding + 15 # 15px left margin inside subnet
687
+ # Center service vertically, accounting for the -8px top padding from renderer
688
+ # Service icon is at y, but box extends 8px above, so offset by 8
689
+ service_y = subnet_y + 8 + (actual_subnet_height - (self.config.icon_size + 44)) / 2
690
+
691
+ for service in subnet_services:
692
+ # Only position if not already positioned (avoid duplicates)
693
+ if service.id not in positions:
694
+ positions[service.id] = Position(
695
+ x=service_x,
696
+ y=service_y,
697
+ width=self.config.icon_size,
698
+ height=self.config.icon_size,
699
+ )
700
+ # Space between services: icon width + box padding (16px) + gap (10px)
701
+ service_x += self.config.icon_size + 26
702
+
703
+ subnet_y += actual_subnet_height + subnet_padding