codebook / potato /server_utils /schemas /span_link.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
12.8 kB
"""
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"""
<div class="span-link-type" data-link-type="{escape_html_content(link_name)}"
data-directed="{str(directed).lower()}"
data-max-spans="{max_spans}"
data-color="{escape_html_content(color)}"
data-source-labels="{escape_html_content(','.join(link_type.get('allowed_source_labels', [])))}"
data-target-labels="{escape_html_content(','.join(link_type.get('allowed_target_labels', [])))}"
{tooltip_attr}>
<input type="radio" name="{escape_html_content(scheme_name)}_link_type"
id="{escape_html_content(scheme_name)}_link_{escape_html_content(link_name)}"
value="{escape_html_content(link_name)}"
class="span-link-type-radio">
<label for="{escape_html_content(scheme_name)}_link_{escape_html_content(link_name)}"
class="span-link-type-label {direction_class}"
style="--link-color: {color}">
<span class="link-color-indicator" style="background-color: {color}"></span>
<span class="link-type-name">{escape_html_content(link_name)}</span>
<span class="link-direction-icon">{direction_icon}</span>
</label>
</div>
"""
# 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"""
<div id="{escape_html_content(scheme_name)}" class="span-link-container annotation-form"
data-annotation-type="span_link"
data-annotation-id="{escape_html_content(str(annotation_scheme.get('annotation_id', scheme_name)))}"
data-span-schema="{escape_html_content(span_schema)}"
data-show-arcs="{str(show_arcs).lower()}"
data-arc-position="{escape_html_content(arc_position)}"
data-show-labels="{str(show_labels).lower()}"
data-multi-line-mode="{escape_html_content(multi_line_mode)}">
<div class="span-link-header">
<h4 class="span-link-title">{escape_html_content(description)}</h4>
</div>
<!-- Link Type Selector -->
<div class="span-link-type-selector">
<label class="span-link-section-label">Select Link Type:</label>
<div class="span-link-types">
{link_types_html}
</div>
</div>
<!-- Selected Spans Display -->
<div class="span-link-selection">
<label class="span-link-section-label">Selected Spans:</label>
<div class="span-link-selected-spans" id="{escape_html_content(scheme_name)}_selected_spans">
<p class="no-selection-message">Click on highlighted spans to select them for linking</p>
</div>
</div>
<!-- Action Buttons -->
<div class="span-link-actions">
<button type="button" class="span-link-create-btn" id="{escape_html_content(scheme_name)}_create_link"
disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
Create Link
</button>
<button type="button" class="span-link-clear-btn" id="{escape_html_content(scheme_name)}_clear_selection"
title="Exit link mode to create new span annotations (Esc)">
Exit Link Mode
</button>
</div>
<!-- Existing Links Display -->
<div class="span-link-existing">
<label class="span-link-section-label">Existing Links:</label>
<div class="span-link-list" id="{escape_html_content(scheme_name)}_link_list">
<p class="no-links-message">No links created yet</p>
</div>
</div>
<!-- Visual Display Toggle -->
<div class="span-link-visual-toggle">
<label>
<input type="checkbox" id="{escape_html_content(scheme_name)}_show_arcs"
{'checked' if show_arcs else ''}>
Show link arcs above text
</label>
</div>
<!-- Hidden input to store link data for form submission -->
<input type="hidden" name="span_link:::{escape_html_content(scheme_name)}"
id="{escape_html_content(scheme_name)}_link_data" value="[]">
</div>
"""
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"""
<path d="M {x1} {y1} Q {mid_x} {y1 - arc_height} {x2} {y2}"
fill="none" stroke="{link_color}" stroke-width="2"
marker-end="url(#arrowhead)"
class="span-link-arc" data-link-id="{link.get_id()}" />
""")
else:
# Undirected link
svg_paths.append(f"""
<path d="M {x1} {y1} Q {mid_x} {y1 - arc_height} {x2} {y2}"
fill="none" stroke="{link_color}" stroke-width="2"
class="span-link-arc" data-link-id="{link.get_id()}" />
""")
# 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"""
<line x1="{x}" y1="{y}" x2="{center_x}" y2="{center_y}"
stroke="{link_color}" stroke-width="2"
class="span-link-arc" data-link-id="{link.get_id()}" />
""")
# Draw central node
svg_paths.append(f"""
<circle cx="{center_x}" cy="{center_y}" r="6"
fill="{link_color}"
class="span-link-node" data-link-id="{link.get_id()}" />
""")
# Wrap in SVG with defs for arrow markers
svg = f"""
<svg class="span-link-arcs-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible;">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
</marker>
</defs>
{''.join(svg_paths)}
</svg>
"""
return svg