Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |