""" Span Link Layout Generates the UI for creating and managing relationships/links between spans. This schema type works in conjunction with a span annotation schema to allow users to annotate relationships like "PERSON works_for ORGANIZATION". """ import logging from .identifier_utils import ( safe_generate_layout, generate_element_identifier, escape_html_content, generate_tooltip_html ) from .span import get_span_color, SPAN_COLOR_PALETTE logger = logging.getLogger(__name__) # Default colors for link types LINK_COLOR_PALETTE = [ "#dc2626", # Red "#22c55e", # Green "#a855f7", # Purple "#f59e0b", # Amber "#3b82f6", # Blue "#ec4899", # Pink "#06b6d4", # Cyan "#f97316", # Orange "#8b5cf6", # Violet "#10b981", # Emerald ] def _generate_span_link_layout_internal(annotation_scheme, horizontal=False): """ Internal function to generate span link layout after validation. Args: annotation_scheme: Configuration dictionary containing: - name: Schema name - description: Description shown to user - span_schema: Name of the span schema to link - link_types: List of link type definitions with: - name: Link type name (e.g., "WORKS_FOR") - directed: Whether the link is directed (default: false) - allowed_source_labels: Optional list of allowed source span labels - allowed_target_labels: Optional list of allowed target span labels - max_spans: Maximum spans in a link (default: 2, higher for n-ary) - color: Optional color for this link type horizontal: Whether to display horizontally (not used for links) Returns: tuple: (HTML string, key bindings list) """ scheme_name = annotation_scheme["name"] description = annotation_scheme.get("description", "Create relationships between spans") span_schema = annotation_scheme.get("span_schema", "") link_types = annotation_scheme.get("link_types", []) visual_display = annotation_scheme.get("visual_display", {}) # Build link types HTML link_types_html = "" key_bindings = [] for i, link_type in enumerate(link_types): link_name = link_type.get("name", f"Link_{i}") directed = link_type.get("directed", False) max_spans = link_type.get("max_spans", 2) color = link_type.get("color", LINK_COLOR_PALETTE[i % len(LINK_COLOR_PALETTE)]) # Direction indicator direction_icon = "→" if directed else "↔" direction_class = "directed" if directed else "undirected" # Tooltip with constraints tooltip_parts = [] if link_type.get("allowed_source_labels"): tooltip_parts.append(f"Source: {', '.join(link_type['allowed_source_labels'])}") if link_type.get("allowed_target_labels"): tooltip_parts.append(f"Target: {', '.join(link_type['allowed_target_labels'])}") if max_spans > 2: tooltip_parts.append(f"N-ary: up to {max_spans} spans") tooltip_attr = "" if tooltip_parts: tooltip_text = "; ".join(tooltip_parts) tooltip_attr = f'data-toggle="tooltip" data-placement="top" title="{escape_html_content(tooltip_text)}"' link_types_html += f""" """ # Visual display settings show_arcs = visual_display.get("enabled", True) arc_position = visual_display.get("arc_position", "above") show_labels = visual_display.get("show_labels", True) # Multi-line arc mode: "single_line" (horizontal scroll) or "bracket" (wrapped text with bracket arcs) multi_line_mode = visual_display.get("multi_line_mode", "bracket") schematic = f""" """ return schematic, key_bindings def generate_span_link_layout(annotation_scheme, horizontal=False): """ Generate span link layout HTML for the given annotation scheme. Args: annotation_scheme (dict): The annotation scheme configuration horizontal (bool): Whether to display horizontally Returns: tuple: (HTML string, key bindings list) """ return safe_generate_layout(annotation_scheme, _generate_span_link_layout_internal, horizontal) def render_link_arcs(links, span_positions): """ Generate SVG arcs for visualizing links between spans. Args: links: List of SpanLink objects span_positions: Dictionary mapping span_id -> {x, y, width, height} positions Returns: str: SVG markup for the link arcs """ if not links or not span_positions: return "" svg_paths = [] for link in links: span_ids = link.get_span_ids() if len(span_ids) < 2: continue # Get color for this link type link_color = link.get_properties().get("color", "#dc2626") # For binary links, draw a simple arc if len(span_ids) == 2: span1_id, span2_id = span_ids if span1_id not in span_positions or span2_id not in span_positions: continue pos1 = span_positions[span1_id] pos2 = span_positions[span2_id] # Calculate arc endpoints (center of each span) x1 = pos1["x"] + pos1["width"] / 2 y1 = pos1["y"] x2 = pos2["x"] + pos2["width"] / 2 y2 = pos2["y"] # Calculate control point for the arc mid_x = (x1 + x2) / 2 arc_height = min(abs(x2 - x1) / 3, 50) # Arc height proportional to distance # Create SVG path if link.is_directed(): # Directed link with arrow svg_paths.append(f""" """) else: # Undirected link svg_paths.append(f""" """) # For n-ary links, connect all spans to a central point else: # Calculate center point valid_positions = [span_positions[sid] for sid in span_ids if sid in span_positions] if len(valid_positions) < 2: continue center_x = sum(p["x"] + p["width"] / 2 for p in valid_positions) / len(valid_positions) center_y = min(p["y"] for p in valid_positions) - 30 # Above the spans # Draw lines from each span to center for span_id in span_ids: if span_id not in span_positions: continue pos = span_positions[span_id] x = pos["x"] + pos["width"] / 2 y = pos["y"] svg_paths.append(f""" """) # Draw central node svg_paths.append(f""" """) # Wrap in SVG with defs for arrow markers svg = f""" {''.join(svg_paths)} """ return svg