| """ |
| Pure-HTML interaction explorer that works without custom JavaScript. |
| |
| We rely on hidden radio inputs + CSS to simulate focus mode: |
| • Default radio (id=...-none) keeps the full chip/sentence list visible. |
| • Clicking any token is just selecting its radio (via <label for="...">). |
| • CSS rules tied to :checked radios fade the base list and reveal the |
| matching overlay panel (center node + neighbors + radial SVG). |
| • Clicking the translucent backdrop or Reset button toggles the “none” |
| radio, restoring the original layout. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| import uuid |
| from collections import defaultdict |
| from html import escape |
| from typing import Dict, List, Tuple |
|
|
|
|
| _FOCUS_VIEW_STYLE = """ |
| <style> |
| .interaction-focus-root { |
| --border-color: #d8d1f0; |
| --bg-panel: #fbf8ff; |
| --text-color: #241d35; |
| border: 1px solid var(--border-color); |
| border-radius: 18px; |
| padding: 18px; |
| background: var(--bg-panel); |
| font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif; |
| position: relative; |
| overflow: hidden; |
| } |
| .interaction-focus-root .focus-radio { |
| display: none; |
| } |
| .interaction-header { |
| font-size: 14px; |
| font-weight: 600; |
| color: #4a3972; |
| margin-bottom: 12px; |
| } |
| .interaction-hint { |
| font-size: 12px; |
| color: #736892; |
| margin-bottom: 18px; |
| } |
| .token-grid { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| } |
| .token-chip { |
| border-radius: 14px; |
| border: 1px solid currentColor; |
| padding: 10px 14px; |
| background: white; |
| min-width: 120px; |
| display: inline-flex; |
| flex-direction: column; |
| gap: 4px; |
| font-size: 13px; |
| color: var(--text-color); |
| cursor: pointer; |
| transition: transform 0.15s ease, box-shadow 0.15s ease; |
| } |
| .token-chip:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 14px 32px rgba(73, 39, 112, 0.15); |
| } |
| .token-chip__value { |
| font-size: 11px; |
| font-weight: 700; |
| letter-spacing: 0.04em; |
| } |
| .token-chip__label { |
| font-weight: 600; |
| white-space: nowrap; |
| } |
| .sentence-list { |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| .sentence-item { |
| border-radius: 16px; |
| border: 1px solid currentColor; |
| background: white; |
| padding: 14px 16px; |
| display: flex; |
| flex-direction: column; |
| gap: 6px; |
| cursor: pointer; |
| transition: box-shadow 0.15s ease, border 0.15s ease; |
| } |
| .sentence-item:hover { |
| box-shadow: 0 18px 36px rgba(51, 26, 92, 0.12); |
| } |
| .sentence-item__header { |
| display: flex; |
| justify-content: space-between; |
| gap: 12px; |
| font-size: 12px; |
| font-weight: 600; |
| color: #2a1f44; |
| } |
| .sentence-item__text { |
| font-size: 13px; |
| color: #2d2441; |
| line-height: 1.4; |
| white-space: normal; |
| } |
| .sentence-item__links { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 6px; |
| font-size: 11px; |
| } |
| .sentence-item__badge { |
| background: rgba(112, 72, 232, 0.1); |
| border-radius: 999px; |
| padding: 2px 10px; |
| color: #5c3fd9; |
| font-weight: 600; |
| } |
| .interaction-focus-overlay { |
| position: absolute; |
| inset: 0; |
| background: rgba(249, 247, 255, 0.96); |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| opacity: 0; |
| pointer-events: none; |
| transition: opacity 0.25s ease; |
| padding: 20px; |
| } |
| .focus-overlay-bg { |
| position: absolute; |
| inset: 0; |
| background: rgba(255, 255, 255, 0.0001); |
| } |
| .focus-panels { |
| position: relative; |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| .focus-panel { |
| display: none; |
| flex-direction: column; |
| gap: 12px; |
| border-radius: 20px; |
| background: white; |
| border: 1px solid rgba(112, 72, 232, 0.18); |
| padding: 18px; |
| box-shadow: 0 18px 38px rgba(66, 40, 122, 0.12); |
| min-height: 340px; |
| } |
| .focus-panel__title { |
| display: flex; |
| justify-content: space-between; |
| align-items: baseline; |
| gap: 12px; |
| } |
| .focus-panel__value { |
| font-size: 18px; |
| font-weight: 800; |
| color: #472f88; |
| } |
| .focus-panel__label { |
| font-size: 14px; |
| font-weight: 600; |
| color: #2d1f44; |
| } |
| .focus-svg { |
| width: 100%; |
| height: 280px; |
| } |
| .focus-links { |
| list-style: none; |
| margin: 0; |
| padding: 0; |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| font-size: 12px; |
| } |
| .focus-links li { |
| background: rgba(112, 72, 232, 0.08); |
| border-radius: 999px; |
| padding: 4px 10px; |
| font-weight: 600; |
| color: #5c3fd9; |
| } |
| .focus-empty-msg { |
| font-size: 12px; |
| color: #7a6d94; |
| } |
| .focus-reset { |
| align-self: flex-end; |
| width: fit-content; |
| border-radius: 999px; |
| padding: 6px 14px; |
| font-weight: 600; |
| background: #f0eaff; |
| color: #5a3dc0; |
| cursor: pointer; |
| } |
| .focus-reset:hover { |
| background: #e0d4ff; |
| } |
| </style> |
| """ |
|
|
|
|
| def _token_colors(value: float, max_abs: float) -> Tuple[str, str]: |
| if max_abs <= 0: |
| return "#f0eef7", "#d5cdea" |
| norm = max(-1.0, min(1.0, value / max_abs)) |
| if norm >= 0: |
| base = (231, 94, 71) |
| else: |
| base = (71, 123, 231) |
| norm = -norm |
| neutral = (245, 242, 252) |
| r = int(round(neutral[0] + (base[0] - neutral[0]) * norm)) |
| g = int(round(neutral[1] + (base[1] - neutral[1]) * norm)) |
| b = int(round(neutral[2] + (base[2] - neutral[2]) * norm)) |
| return f"rgba({r}, {g}, {b}, 0.35)", f"rgb({r}, {g}, {b})" |
|
|
|
|
| def create_interaction_token_view( |
| features: List[str], |
| feature_values: List[float], |
| pairwise: List[Tuple[Tuple[str, ...], float]], |
| *, |
| method: str = "shapley", |
| layout: str = "token", |
| max_links: int = 6, |
| ) -> str: |
| if not features: |
| return "<div class='interaction-hint'>No tokens available.</div>" |
|
|
| values = list(feature_values) if feature_values else [] |
| if len(values) < len(features): |
| values.extend([0.0] * (len(features) - len(values))) |
|
|
| layout = (layout or "token").lower() |
| if layout not in {"token", "sentence"}: |
| layout = "token" |
|
|
| adjacency: Dict[str, List[Tuple[str, float]]] = defaultdict(list) |
| for feats, val in pairwise: |
| if len(feats) != 2: |
| continue |
| a, b = feats |
| adjacency[a].append((b, float(val))) |
| adjacency[b].append((a, float(val))) |
|
|
| if not adjacency and len(features) > 1: |
| for idx in range(len(features) - 1): |
| score = 0.5 * (values[idx] + values[idx + 1]) |
| adjacency[features[idx]].append((features[idx + 1], float(score))) |
| adjacency[features[idx + 1]].append((features[idx], float(score))) |
|
|
| for key in adjacency: |
| adjacency[key].sort(key=lambda item: abs(item[1]), reverse=True) |
|
|
| view_id = f"interaction-focus-{uuid.uuid4().hex[:10]}" |
| none_radio = f"{view_id}-focus-none" |
| radios = [f'<input type="radio" name="{view_id}-focus" id="{none_radio}" class="focus-radio" checked>'] |
| radio_ids: List[str] = [] |
| for idx in range(len(features)): |
| rid = f"{view_id}-focus-{idx}" |
| radio_ids.append(rid) |
| radios.append(f'<input type="radio" name="{view_id}-focus" id="{rid}" class="focus-radio">') |
|
|
| base_html = _render_base_listing(features, values, adjacency, layout, radio_ids) |
| panels = _build_focus_panels(features, values, adjacency, radio_ids, max_links) |
|
|
| css_rules = [ |
| f"#{none_radio}:checked ~ .interaction-base {{opacity:1; filter:none; pointer-events:auto;}}", |
| f"#{none_radio}:checked ~ .interaction-focus-overlay {{opacity:0; pointer-events:none;}}", |
| ] |
| for idx, rid in enumerate(radio_ids): |
| css_rules.append( |
| f"#{rid}:checked ~ .interaction-base {{opacity:0.08; filter:blur(1px); pointer-events:none;}}" |
| ) |
| css_rules.append( |
| f"#{rid}:checked ~ .interaction-focus-overlay {{opacity:1; pointer-events:auto;}}" |
| ) |
| css_rules.append( |
| f"#{rid}:checked ~ .interaction-focus-overlay .focus-panel[data-node='{idx}'] {{display:flex;}}" |
| ) |
|
|
| return "".join( |
| [ |
| _FOCUS_VIEW_STYLE, |
| "<style>", |
| "\n".join(css_rules), |
| "</style>", |
| f'<div class="interaction-focus-root" id="{view_id}">', |
| "".join(radios), |
| f'<div class="interaction-header">{escape(method.title())} pairwise interactions</div>', |
| f'<div class="interaction-hint">点击任意{"句子" if layout == "sentence" else "单词"}即可聚焦并查看其与其它片段的互动。</div>', |
| '<div class="interaction-base">', |
| base_html, |
| "</div>", |
| '<div class="interaction-focus-overlay" aria-hidden="true">', |
| f'<label class="focus-overlay-bg" for="{none_radio}" title="点击恢复"></label>', |
| '<div class="focus-panels">', |
| "".join(panels), |
| f'<label class="focus-reset" for="{none_radio}">重置 / 返回</label>', |
| "</div>", |
| "</div>", |
| "</div>", |
| ] |
| ) |
|
|
|
|
| def _render_base_listing( |
| features: List[str], |
| values: List[float], |
| adjacency: Dict[str, List[Tuple[str, float]]], |
| layout: str, |
| radio_ids: List[str], |
| ) -> str: |
| max_abs = max((abs(v) for v in values), default=0.0) or 1.0 |
| chunks: List[str] = [] |
|
|
| if layout == "sentence": |
| for idx, token in enumerate(features): |
| bg, border = _token_colors(values[idx], max_abs) |
| badges = [] |
| for partner, score in adjacency.get(token, [])[:2]: |
| sign = "+" if score >= 0 else "" |
| badges.append( |
| f"<span class='sentence-item__badge'>{escape(partner)} {sign}{score:.2f}</span>" |
| ) |
| badge_html = "" |
| if badges: |
| badge_html = ( |
| "<div class='sentence-item__links'>" |
| + "".join(badges) |
| + "</div>" |
| ) |
| chunks.append( |
| f'<label for="{radio_ids[idx]}" class="sentence-item" ' |
| f'style="color:{border}; border-color:{border}; background:{bg};">' |
| f'<div class="sentence-item__header"><span>{values[idx]:+0.2f}</span>' |
| f"<span>#{idx + 1}</span></div>" |
| f'<div class="sentence-item__text">{escape(token)}</div>' |
| f"{badge_html}" |
| "</label>" |
| ) |
| return '<div class="sentence-list">' + "".join(chunks) + "</div>" |
|
|
| for idx, token in enumerate(features): |
| bg, border = _token_colors(values[idx], max_abs) |
| chunks.append( |
| f'<label for="{radio_ids[idx]}" class="token-chip" ' |
| f'style="background:{bg}; color:{border}; border-color:{border};">' |
| f'<span class="token-chip__value">{values[idx]:+0.2f}</span>' |
| f'<span class="token-chip__label">{escape(token)}</span>' |
| "</label>" |
| ) |
| return '<div class="token-grid">' + "".join(chunks) + "</div>" |
|
|
|
|
| def _build_focus_panels( |
| features: List[str], |
| values: List[float], |
| adjacency: Dict[str, List[Tuple[str, float]]], |
| radio_ids: List[str], |
| max_links: int, |
| ) -> List[str]: |
| panels: List[str] = [] |
| value_max = max((abs(v) for v in values), default=0.0) or 1.0 |
| for idx, token in enumerate(features): |
| neighbors = adjacency.get(token, [])[:max_links] |
| svg = _render_focus_svg(token, values[idx], neighbors, value_max) |
| if neighbors: |
| links_html = "<ul class='focus-links'>" + "".join( |
| f"<li>{escape(partner)} {score:+.2f}</li>" for partner, score in neighbors |
| ) + "</ul>" |
| else: |
| links_html = "<div class='focus-empty-msg'>暂无相关连接</div>" |
|
|
| panels.append( |
| f'<div class="focus-panel" data-node="{idx}">' |
| f'<div class="focus-panel__title">' |
| f'<span class="focus-panel__value">{values[idx]:+0.3f}</span>' |
| f'<span class="focus-panel__label">{escape(token)}</span>' |
| "</div>" |
| f"{svg}" |
| f"{links_html}" |
| "</div>" |
| ) |
| return panels |
|
|
|
|
| def _render_focus_svg( |
| label: str, |
| value: float, |
| neighbors: List[Tuple[str, float]], |
| max_abs_value: float, |
| ) -> str: |
| width = 420.0 |
| height = 300.0 |
| cx, cy = width / 2.0, height / 2.0 |
| radius = min(width, height) * 0.35 |
|
|
| center_fill, center_border = _token_colors(value, max_abs_value) |
| svg_parts = [ |
| f'<svg viewBox="0 0 {width:.0f} {height:.0f}" class="focus-svg">' |
| f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="34" fill="{center_fill}" stroke="{center_border}" stroke-width="2.5" />' |
| f'<text x="{cx:.1f}" y="{cy - 4:.1f}" text-anchor="middle" font-size="13" font-weight="700" fill="#2a1f44">{value:+.2f}</text>' |
| f'<text x="{cx:.1f}" y="{cy + 16:.1f}" text-anchor="middle" font-size="12" fill="#675f82">{escape(label[:42])}</text>' |
| ] |
|
|
| if not neighbors: |
| svg_parts.append("</svg>") |
| return "".join(svg_parts) |
|
|
| max_edge = max((abs(val) for _, val in neighbors), default=0.0) or 1.0 |
| step = (2 * math.pi) / max(len(neighbors), 1) |
| for i, (partner, score) in enumerate(neighbors): |
| angle = -math.pi / 2 + i * step |
| x = cx + radius * math.cos(angle) |
| y = cy + radius * math.sin(angle) |
| color = "#d35400" if score >= 0 else "#3867d6" |
| stroke_width = 1.4 + 4.0 * (abs(score) / max_edge) |
|
|
| svg_parts.append( |
| f'<line x1="{cx:.1f}" y1="{cy:.1f}" x2="{x:.1f}" y2="{y:.1f}" ' |
| f'stroke="{color}" stroke-width="{stroke_width:.2f}" stroke-linecap="round" opacity="0.9" />' |
| ) |
| svg_parts.append( |
| f'<text x="{(cx + x) / 2:.1f}" y="{(cy + y) / 2 - 6:.1f}" text-anchor="middle" ' |
| f'font-size="11" font-weight="600" fill="#4a3b80">{score:+.2f}</text>' |
| ) |
| node_fill, node_border = _token_colors(score, max_edge) |
| svg_parts.append( |
| f'<circle cx="{x:.1f}" cy="{y:.1f}" r="18" fill="{node_fill}" stroke="{node_border}" stroke-width="2" />' |
| ) |
| svg_parts.append( |
| f'<text x="{x:.1f}" y="{y + 28:.1f}" text-anchor="middle" font-size="11" fill="#2a1f44">' |
| f"{escape(partner[:32])}</text>" |
| ) |
|
|
| svg_parts.append("</svg>") |
| return "".join(svg_parts) |
|
|