dataform-dependency-visualizer 0.2.3__tar.gz → 0.2.4__tar.gz

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.
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: dataform-dependency-visualizer
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Visualize Dataform table dependencies as interactive SVG diagrams
5
- Home-page: https://github.com/OshigeAkito/dataform-dependency-visualizer
6
5
  License: MIT
6
+ License-File: LICENSE
7
7
  Keywords: dataform,dependencies,visualization,bigquery,sql
8
8
  Author: Thamo
9
9
  Author-email: thamo@example.com
@@ -15,8 +15,11 @@ Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
18
20
  Classifier: Topic :: Database
19
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Project-URL: Homepage, https://github.com/OshigeAkito/dataform-dependency-visualizer
20
23
  Project-URL: Repository, https://github.com/OshigeAkito/dataform-dependency-visualizer
21
24
  Description-Content-Type: text/markdown
22
25
 
@@ -32,7 +35,8 @@ Generate beautiful, interactive SVG diagrams showing dependencies between Datafo
32
35
 
33
36
  - 📊 **Interactive SVG Diagrams** - Visualize dependencies for each table
34
37
  - 🎨 **Color-Coded** - Tables (blue), views (green), operations (orange)
35
- - 🔍 **Master Index** - Browse all tables in one HTML interface
38
+ - **JOIN Details** - Shows JOIN types and ON conditions for each dependency
39
+ - �🔍 **Master Index** - Browse all tables in one HTML interface
36
40
  - 📁 **Schema Organization** - Automatically organized by database schema
37
41
  - ⚡ **Pure Python** - No Graphviz required
38
42
 
@@ -115,10 +119,11 @@ viz.generate_master_index('output')
115
119
  Each SVG diagram shows:
116
120
 
117
121
  - **Center (Yellow)**: The table being viewed
118
- - **Left (Blue)**: Dependencies - tables this reads FROM
122
+ - **Left (Blue)**: Dependencies - tables this reads FROM, with JOIN details shown below each
119
123
  - **Right (Green)**: Dependents - tables that read FROM this
120
124
  - **Schema Labels**: Database schema for each table
121
125
  - **Type Badges**: table, view, incremental, operation
126
+ - **JOIN Information**: Type (LEFT/INNER/RIGHT JOIN) and ON conditions displayed in red below dependency nodes
122
127
 
123
128
  ## Output Structure
124
129
 
@@ -180,6 +185,12 @@ MIT License - See [LICENSE](LICENSE) file
180
185
 
181
186
  ## Changelog
182
187
 
188
+ ### v0.2.3 (2026-01-21)
189
+ - **NEW**: JOIN information extraction from SQL queries
190
+ - Display JOIN types (LEFT, INNER, RIGHT, etc.) and ON conditions in SVG diagrams
191
+ - JOIN count summary shown on center table
192
+ - Detailed JOIN info displayed below each dependency node
193
+
183
194
  ### v0.2.0 (2026-01-13)
184
195
  - Added cleanup utility for database references
185
196
  - Constant replacement functionality
@@ -10,7 +10,8 @@ Generate beautiful, interactive SVG diagrams showing dependencies between Datafo
10
10
 
11
11
  - 📊 **Interactive SVG Diagrams** - Visualize dependencies for each table
12
12
  - 🎨 **Color-Coded** - Tables (blue), views (green), operations (orange)
13
- - 🔍 **Master Index** - Browse all tables in one HTML interface
13
+ - **JOIN Details** - Shows JOIN types and ON conditions for each dependency
14
+ - �🔍 **Master Index** - Browse all tables in one HTML interface
14
15
  - 📁 **Schema Organization** - Automatically organized by database schema
15
16
  - ⚡ **Pure Python** - No Graphviz required
16
17
 
@@ -93,10 +94,11 @@ viz.generate_master_index('output')
93
94
  Each SVG diagram shows:
94
95
 
95
96
  - **Center (Yellow)**: The table being viewed
96
- - **Left (Blue)**: Dependencies - tables this reads FROM
97
+ - **Left (Blue)**: Dependencies - tables this reads FROM, with JOIN details shown below each
97
98
  - **Right (Green)**: Dependents - tables that read FROM this
98
99
  - **Schema Labels**: Database schema for each table
99
100
  - **Type Badges**: table, view, incremental, operation
101
+ - **JOIN Information**: Type (LEFT/INNER/RIGHT JOIN) and ON conditions displayed in red below dependency nodes
100
102
 
101
103
  ## Output Structure
102
104
 
@@ -158,6 +160,12 @@ MIT License - See [LICENSE](LICENSE) file
158
160
 
159
161
  ## Changelog
160
162
 
163
+ ### v0.2.3 (2026-01-21)
164
+ - **NEW**: JOIN information extraction from SQL queries
165
+ - Display JOIN types (LEFT, INNER, RIGHT, etc.) and ON conditions in SVG diagrams
166
+ - JOIN count summary shown on center table
167
+ - Detailed JOIN info displayed below each dependency node
168
+
161
169
  ### v0.2.0 (2026-01-13)
162
170
  - Added cleanup utility for database references
163
171
  - Constant replacement functionality
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "dataform-dependency-visualizer"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Visualize Dataform table dependencies as interactive SVG diagrams"
5
5
  readme = "README.md"
6
6
  authors = ["Thamo <thamo@example.com>"]
@@ -269,10 +269,213 @@ def normalize_name(target):
269
269
  return "UNKNOWN"
270
270
  return f"{target.get('schema', '')}.{target.get('name', '')}"
271
271
 
272
+ def parse_joins_from_query(query, dependencies):
273
+ """Extract JOIN information from SQL query.
274
+
275
+ Args:
276
+ query: SQL query string
277
+ dependencies: List of dependency target objects
278
+
279
+ Returns:
280
+ Dictionary mapping dependency name to join info: {dep_name: {'type': 'LEFT JOIN', 'condition': 'a.id = b.id'}}
281
+ """
282
+ if not query:
283
+ return {}
284
+
285
+ join_info = {}
286
+
287
+ # Normalize query - remove backticks and newlines for easier parsing
288
+ query_normalized = query.replace('`', '').replace('\n', ' ').replace('\r', '')
289
+
290
+ # Extract CTE definitions to map CTE names to their source tables
291
+ # For CTEs with complex content (window functions, nested parentheses), we need a better approach
292
+ # Pattern: WITH cte_name AS (... FROM schema.table_name ...)
293
+ cte_to_source = {} # Maps CTE name to source table
294
+
295
+ # Find all CTE definitions (WITH or , followed by cte_name AS ()
296
+ cte_start_pattern = r'(?:WITH|,)\s+(\w+)\s+AS\s*\('
297
+ cte_starts = list(re.finditer(cte_start_pattern, query_normalized, re.IGNORECASE))
298
+
299
+ for i, match in enumerate(cte_starts):
300
+ cte_name = match.group(1).strip()
301
+ start_pos = match.end() # Position after the opening (
302
+
303
+ # Find the closing parenthesis for this CTE by tracking paren depth
304
+ paren_depth = 1
305
+ pos = start_pos
306
+ cte_end_pos = start_pos
307
+
308
+ while pos < len(query_normalized) and paren_depth > 0:
309
+ if query_normalized[pos] == '(':
310
+ paren_depth += 1
311
+ elif query_normalized[pos] == ')':
312
+ paren_depth -= 1
313
+ if paren_depth == 0:
314
+ cte_end_pos = pos
315
+ break
316
+ pos += 1
317
+
318
+ # Extract the CTE content
319
+ cte_content = query_normalized[start_pos:cte_end_pos]
320
+
321
+ # Look for FROM clauses in the CTE content
322
+ # Pattern: FROM schema.table_name or FROM table_name
323
+ from_pattern = r'FROM\s+([\w.-]+)'
324
+ from_matches = list(re.finditer(from_pattern, cte_content, re.IGNORECASE))
325
+
326
+ if from_matches:
327
+ # Use the first FROM clause (primary table)
328
+ source_table = from_matches[0].group(1).strip()
329
+ cte_to_source[cte_name.lower()] = source_table
330
+
331
+ # Recursively resolve CTEs that reference other CTEs
332
+ # (e.g., annulus_level -> extracted_casing_size -> refined_wisdom_v_csg_tbg)
333
+ max_iterations = 10 # Prevent infinite loops
334
+ for _ in range(max_iterations):
335
+ resolved_any = False
336
+ for cte_name, source_table in list(cte_to_source.items()):
337
+ # Check if source_table is itself a CTE
338
+ if source_table.lower() in cte_to_source:
339
+ # Resolve it
340
+ cte_to_source[cte_name] = cte_to_source[source_table.lower()]
341
+ resolved_any = True
342
+ if not resolved_any:
343
+ break
344
+
345
+ # Build list of dependency table names to look for
346
+ dep_names = []
347
+ dep_name_parts = [] # Store partial name matches for CTEs
348
+ for dep in dependencies:
349
+ if isinstance(dep, dict):
350
+ schema = dep.get('schema', '')
351
+ name = dep.get('name', '')
352
+ full_name = f"{schema}.{name}"
353
+ dep_names.append((full_name, full_name))
354
+ # Also add just the table name for matching
355
+ dep_names.append((name, full_name))
356
+ # Add partial name matching for CTEs (e.g., union_a_ann_barrier -> a_ann_barrier)
357
+ if '_' in name:
358
+ parts = name.split('_')
359
+ # Try various partial combinations
360
+ if name.startswith('union_'):
361
+ # For union_xxx patterns, match xxx
362
+ cte_name = name.replace('union_', '')
363
+ dep_names.append((cte_name, full_name))
364
+ # Try last few parts joined (for patterns like union_a_ann_barrier -> a_ann_barrier)
365
+ if len(parts) > 2:
366
+ for i in range(1, len(parts)):
367
+ partial = '_'.join(parts[i:])
368
+ dep_names.append((partial, full_name))
369
+
370
+ # Regex pattern to match JOIN clauses with ON
371
+ # Captures: (join_type) table_ref [alias] ON condition
372
+ join_on_pattern = r'((?:INNER|LEFT|RIGHT|FULL|CROSS)?\s*(?:OUTER)?\s*JOIN)\s+([\w.-]+)(?:\s+(?:AS\s+)?([\w]+))?\s+ON\s+([^\r\n]+?)(?=\s+(?:WHERE|GROUP|HAVING|ORDER|LIMIT|UNION|FROM|SELECT|INNER\s+JOIN|LEFT\s+JOIN|RIGHT\s+JOIN|FULL\s+JOIN|CROSS\s+JOIN|JOIN|\)|;)\s*|$)'
373
+
374
+ # Regex pattern to match JOIN clauses with USING
375
+ # Captures: (join_type) table_ref [alias] USING (columns)
376
+ join_using_pattern = r'((?:INNER|LEFT|RIGHT|FULL|CROSS)?\s*(?:OUTER)?\s*JOIN)\s+([\w.-]+)(?:\s+(?:AS\s+)?([\w]+))?\s+USING\s*\(([^)]+)\)'
377
+
378
+ # Process ON joins
379
+ matches = re.finditer(join_on_pattern, query_normalized, re.IGNORECASE)
380
+
381
+ # Debug: Count matches
382
+ match_list = list(matches)
383
+ if len(match_list) > 0:
384
+ print(f"DEBUG parse_joins: Found {len(match_list)} ON joins in query")
385
+ matches = iter(match_list) # Convert back to iterator
386
+
387
+ for match in matches:
388
+ join_type = match.group(1).strip().upper() # e.g., "LEFT JOIN"
389
+ table_ref = match.group(2).strip() # e.g., "schema.table_name" or "table_name"
390
+ # alias = match.group(3) # Optional alias
391
+ condition = match.group(4).strip() if match.group(4) else "" # ON condition
392
+
393
+ # Check if table_ref is a CTE - if so, resolve it to the actual source table
394
+ resolved_table_ref = table_ref
395
+ if table_ref.lower() in cte_to_source:
396
+ resolved_table_ref = cte_to_source[table_ref.lower()]
397
+
398
+ # Normalize resolved_table_ref - remove database prefix (e.g., database.schema.table -> schema.table)
399
+ # Format: database-name.schema.table or just schema.table
400
+ resolved_parts = resolved_table_ref.split('.')
401
+ if len(resolved_parts) == 3:
402
+ # Has database prefix, remove it
403
+ resolved_table_ref = f"{resolved_parts[1]}.{resolved_parts[2]}"
404
+
405
+ # Try to match this table_ref to one of our dependencies
406
+ matched_dep = None
407
+ best_match_len = 0
408
+
409
+ for search_name, full_dep_name in dep_names:
410
+ # Check if the search_name matches the resolved table_ref in the JOIN
411
+ # Use case-insensitive comparison and check both directions
412
+ if (search_name.lower() in resolved_table_ref.lower() or
413
+ resolved_table_ref.lower() in search_name.lower() or
414
+ search_name.lower() == resolved_table_ref.lower()):
415
+ # Prefer longer matches (more specific)
416
+ if len(search_name) > best_match_len:
417
+ matched_dep = full_dep_name
418
+ best_match_len = len(search_name)
419
+
420
+ if matched_dep and condition:
421
+ # Clean up condition - remove extra whitespace
422
+ condition = ' '.join(condition.split())
423
+ join_info[matched_dep] = {
424
+ 'type': join_type,
425
+ 'condition': condition
426
+ }
427
+ # Debug output
428
+ print(f"DEBUG: Matched JOIN: {join_type} {table_ref} -> {matched_dep}")
429
+
430
+ # Process USING joins
431
+ matches = re.finditer(join_using_pattern, query_normalized, re.IGNORECASE)
432
+
433
+ for match in matches:
434
+ join_type = match.group(1).strip().upper() # e.g., "LEFT JOIN"
435
+ table_ref = match.group(2).strip() # e.g., "schema.table_name" or "table_name"
436
+ # alias = match.group(3) # Optional alias
437
+ columns = match.group(4).strip() if match.group(4) else "" # USING columns
438
+
439
+ # Check if table_ref is a CTE - if so, resolve it to the actual source table
440
+ resolved_table_ref = table_ref
441
+ if table_ref.lower() in cte_to_source:
442
+ resolved_table_ref = cte_to_source[table_ref.lower()]
443
+
444
+ # Normalize resolved_table_ref - remove database prefix (e.g., database.schema.table -> schema.table)
445
+ resolved_parts = resolved_table_ref.split('.')
446
+ if len(resolved_parts) == 3:
447
+ # Has database prefix, remove it
448
+ resolved_table_ref = f"{resolved_parts[1]}.{resolved_parts[2]}"
449
+
450
+ # Try to match this table_ref to one of our dependencies
451
+ matched_dep = None
452
+ best_match_len = 0
453
+
454
+ for search_name, full_dep_name in dep_names:
455
+ if (search_name.lower() in resolved_table_ref.lower() or
456
+ resolved_table_ref.lower() in search_name.lower() or
457
+ search_name.lower() == resolved_table_ref.lower()):
458
+ if len(search_name) > best_match_len:
459
+ matched_dep = full_dep_name
460
+ best_match_len = len(search_name)
461
+
462
+ if matched_dep and columns:
463
+ # Format as "USING (columns)"
464
+ condition = f"USING ({columns})"
465
+ join_info[matched_dep] = {
466
+ 'type': join_type,
467
+ 'condition': condition
468
+ }
469
+
470
+ return join_info
471
+
272
472
  def main():
473
+ print("DEBUG: main() started")
273
474
  graph = get_dataform_graph()
274
475
  if not graph:
476
+ print("DEBUG: No graph returned from get_dataform_graph")
275
477
  return
478
+ print("DEBUG: Graph loaded successfully")
276
479
 
277
480
  tables = graph.get("tables", [])
278
481
 
@@ -288,13 +491,23 @@ def main():
288
491
  full_name = normalize_name(tgt) # e.g. "dataset.table"
289
492
  short_name = tgt.get("name")
290
493
 
494
+ # Get dependencies
495
+ deps = t.get("dependencyTargets", [])
496
+ query = t.get("query", "")
497
+
498
+ # Debug: Check if query exists for specific table
499
+ if 'cmt_perf' in short_name:
500
+ print(f"DEBUG main: Processing {full_name}, query length: {len(query)}, deps: {len(deps)}")
501
+
502
+ # Parse JOIN information from query
503
+ join_info = parse_joins_from_query(query, deps)
504
+
291
505
  # Store using full name as key
292
506
  table_lookup[full_name] = {
293
507
  "type": t.get("type"),
294
- "dependencies": t.get("dependencyTargets", []), # dataform 2.x often uses dependencyTargets (objects) or dependencies (strings)
295
- # recent dataform versions might strictly use dependencyTargets. Let's check both or inspect.
296
- # If dependencyTargets matches the current structure, otherwise fallback.
297
- "dependents": []
508
+ "dependencies": deps,
509
+ "dependents": [],
510
+ "join_info": join_info
298
511
  }
299
512
 
300
513
  # Also store short name pointer if it doesn't conflict?
@@ -337,7 +550,14 @@ def main():
337
550
  deps = info['dependencies']
338
551
  print(f" Dependencies ({len(deps)}):")
339
552
  for d in deps:
340
- print(f" <- {normalize_name(d)}")
553
+ dep_name = normalize_name(d)
554
+ print(f" <- {dep_name}")
555
+
556
+ # Print JOIN information if available
557
+ join_info = info.get('join_info', {})
558
+ if dep_name in join_info:
559
+ j = join_info[dep_name]
560
+ print(f" {j['type']} ON {j['condition']}")
341
561
 
342
562
  depts = info['dependents']
343
563
  print(f" Dependents ({len(depts)}):")
@@ -14,10 +14,11 @@ def parse_dependencies_report(report_path: str) -> Dict[str, dict]:
14
14
  report_path: Path to dependencies report file
15
15
 
16
16
  Returns:
17
- Dictionary mapping table names to their info (type, dependencies, dependents)
17
+ Dictionary mapping table names to their info (type, dependencies, dependents, join_info)
18
18
  """
19
19
  tables = {}
20
20
  current_table = None
21
+ current_dep = None
21
22
 
22
23
  # Try different encodings
23
24
  encodings = ['utf-8', 'utf-16', 'cp1252', 'latin-1']
@@ -47,10 +48,12 @@ def parse_dependencies_report(report_path: str) -> Dict[str, dict]:
47
48
  table_name = table_match.group(1)
48
49
  table_type = table_match.group(2)
49
50
  current_table = table_name
51
+ current_dep = None
50
52
  tables[current_table] = {
51
53
  'type': table_type,
52
54
  'dependencies': [],
53
- 'dependents': []
55
+ 'dependents': [],
56
+ 'join_info': {}
54
57
  }
55
58
  continue
56
59
 
@@ -59,11 +62,26 @@ def parse_dependencies_report(report_path: str) -> Dict[str, dict]:
59
62
  dep = line.strip().replace('<- ', '').strip()
60
63
  if dep:
61
64
  tables[current_table]['dependencies'].append(dep)
65
+ current_dep = dep
66
+
67
+ # Match join info line (indented further, contains JOIN and ON)
68
+ elif current_table and current_dep and 'JOIN' in line and 'ON' in line:
69
+ join_line = line.strip()
70
+ # Parse join info: "LEFT JOIN ON condition"
71
+ join_match = re.match(r'(\w+\s+JOIN)\s+ON\s+(.+)', join_line)
72
+ if join_match:
73
+ join_type = join_match.group(1)
74
+ join_condition = join_match.group(2)
75
+ tables[current_table]['join_info'][current_dep] = {
76
+ 'type': join_type,
77
+ 'condition': join_condition
78
+ }
62
79
 
63
80
  # Match dependent line
64
- if current_table and '->' in line:
81
+ elif current_table and '->' in line:
65
82
  dep = line.strip().replace('-> ', '').strip()
66
83
  if dep:
67
84
  tables[current_table]['dependents'].append(dep)
85
+ current_dep = None
68
86
 
69
87
  return tables
@@ -11,6 +11,7 @@ def parse_dependencies_report(report_path):
11
11
  """Parse the dependencies_report.txt file"""
12
12
  tables = {}
13
13
  current_table = None
14
+ current_dep = None
14
15
 
15
16
  # Try different encodings
16
17
  encodings = ['utf-8', 'utf-16', 'cp1252', 'latin-1']
@@ -36,10 +37,12 @@ def parse_dependencies_report(report_path):
36
37
  table_name = table_match.group(1)
37
38
  table_type = table_match.group(2)
38
39
  current_table = table_name
40
+ current_dep = None
39
41
  tables[current_table] = {
40
42
  'type': table_type,
41
43
  'dependencies': [],
42
- 'dependents': []
44
+ 'dependents': [],
45
+ 'join_info': {}
43
46
  }
44
47
  continue
45
48
 
@@ -48,12 +51,27 @@ def parse_dependencies_report(report_path):
48
51
  dep = line.strip().replace('<- ', '').strip()
49
52
  if dep:
50
53
  tables[current_table]['dependencies'].append(dep)
54
+ current_dep = dep
55
+
56
+ # Match join info line (indented further, contains JOIN and ON)
57
+ elif current_table and current_dep and 'JOIN' in line and 'ON' in line:
58
+ join_line = line.strip()
59
+ # Parse join info: "LEFT JOIN ON condition"
60
+ join_match = re.match(r'(\w+\s+JOIN)\s+ON\s+(.+)', join_line)
61
+ if join_match:
62
+ join_type = join_match.group(1)
63
+ join_condition = join_match.group(2)
64
+ tables[current_table]['join_info'][current_dep] = {
65
+ 'type': join_type,
66
+ 'condition': join_condition
67
+ }
51
68
 
52
69
  # Match dependent line
53
- if current_table and '->' in line:
70
+ elif current_table and '->' in line:
54
71
  dep = line.strip().replace('-> ', '').strip()
55
72
  if dep:
56
73
  tables[current_table]['dependents'].append(dep)
74
+ current_dep = None
57
75
 
58
76
  return tables
59
77
 
@@ -159,6 +177,8 @@ def generate_svg_manual(table_name, table_info, all_tables, svg_file):
159
177
  ' <style>',
160
178
  ' .node-text { font-family: Arial, sans-serif; font-size: 12px; fill: #333; }',
161
179
  ' .type-badge { font-family: Arial, sans-serif; font-size: 10px; fill: #666; }',
180
+ ' .join-label { font-family: Arial, sans-serif; font-size: 10px; font-weight: bold; }',
181
+ ' .join-condition { font-family: Arial, sans-serif; font-size: 9px; }',
162
182
  ' </style>',
163
183
  ]
164
184
 
@@ -225,6 +245,29 @@ def generate_svg_manual(table_name, table_info, all_tables, svg_file):
225
245
  # Type badge
226
246
  badge_y = pos['y'] + 20 if schema_name else pos['y'] + 15
227
247
  svg_lines.append(f' <text x="{pos["x"]}" y="{badge_y}" text-anchor="middle" class="type-badge">{pos["type"]}</text>')
248
+
249
+ # Add JOIN information below dependency nodes (not the center table)
250
+ if node_name != table_name and node_name in dependencies:
251
+ join_info = table_info.get('join_info', {}).get(node_name)
252
+ if join_info:
253
+ join_y = pos['y'] + node_height // 2 + 15
254
+ join_text = f"{join_info['type']}"
255
+
256
+ # Truncate condition if too long
257
+ condition = join_info['condition']
258
+ if len(condition) > 35:
259
+ condition = condition[:32] + '...'
260
+
261
+ # Draw JOIN type
262
+ text_width = len(join_text) * 6
263
+ svg_lines.append(f' <rect x="{pos["x"] - text_width//2 - 4}" y="{join_y - 12}" width="{text_width + 8}" height="14" fill="white" opacity="0.9" rx="2" />')
264
+ svg_lines.append(f' <text x="{pos["x"]}" y="{join_y}" text-anchor="middle" class="join-label" fill="#d32f2f">{join_text}</text>')
265
+
266
+ # Draw JOIN condition on second line
267
+ join_y += 16
268
+ cond_width = len(condition) * 5
269
+ svg_lines.append(f' <rect x="{pos["x"] - cond_width//2 - 4}" y="{join_y - 12}" width="{cond_width + 8}" height="14" fill="white" opacity="0.9" rx="2" />')
270
+ svg_lines.append(f' <text x="{pos["x"]}" y="{join_y}" text-anchor="middle" class="join-condition" fill="#666">{condition}</text>')
228
271
 
229
272
  svg_lines.append('</svg>')
230
273