AttrLLM / visualization /plotting /text_interactions.py
Qingpeng Kong
clean initial state
3e72399
from __future__ import annotations
import json
import uuid
from html import escape
from typing import Any, Dict, List, Sequence
_NEGATIVE_RGB = (221, 19, 19)
_POSITIVE_RGB = (74, 28, 135)
_NEUTRAL_RGB = (235, 228, 244)
def _interpolated_rgb(value: float, max_abs: float) -> tuple[int, int, int]:
if max_abs <= 0:
return _NEUTRAL_RGB
norm = max(-1.0, min(1.0, value / max_abs))
t = (norm + 1.0) / 2.0
if t < 0.5:
local = t * 2.0
return tuple(
int(round(_NEGATIVE_RGB[i] + (_NEUTRAL_RGB[i] - _NEGATIVE_RGB[i]) * local))
for i in range(3)
)
local = (t - 0.5) * 2.0
return tuple(
int(round(_NEUTRAL_RGB[i] + (_POSITIVE_RGB[i] - _NEUTRAL_RGB[i]) * local))
for i in range(3)
)
def _strip_occurrence_suffix(text: str) -> str:
text = text or ""
if text.endswith(")") and " (" in text:
base, _, tail = text.rpartition(" (")
if tail[:-1].isdigit():
return base
return text
def _value_to_color(value: float, max_abs: float) -> str:
rgb = _interpolated_rgb(value, max_abs)
return f"rgb({rgb[0]}, {rgb[1]}, {rgb[2]})"
def _text_color_for_value(value: float, max_abs: float) -> str:
rgb = _interpolated_rgb(value, max_abs)
luminance = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255.0
if luminance < 0.42:
return "#ffffff"
return "#020617"
def _coerce_marginals(features: Sequence[str], marginals: Any) -> List[float]:
if marginals is None:
return [0.0 for _ in features]
if isinstance(marginals, dict):
return [float(marginals.get(f, 0.0)) for f in features]
if isinstance(marginals, (list, tuple)):
values: List[float] = []
for idx, feat in enumerate(features):
try:
values.append(float(marginals[idx]))
except Exception:
values.append(0.0)
return values
return [0.0 for _ in features]
def _sanitize_edges(
interactions: Sequence[Dict[str, Any]],
feature_count: int,
top_k: int,
threshold: float,
) -> List[Dict[str, Any]]:
edges: List[Dict[str, Any]] = []
seen: set = set()
for item in interactions:
if not isinstance(item, dict):
continue
indices = item.get("indices")
if not indices or len(indices) != 2:
continue
try:
i = int(indices[0])
j = int(indices[1])
value = float(item.get("value", 0.0))
except Exception:
continue
if i < 0 or j < 0 or i >= feature_count or j >= feature_count or i == j:
continue
if abs(value) < threshold:
continue
# Deduplicate (i,j) and (j,i) — keep canonical form with smaller index first.
canonical = (min(i, j), max(i, j))
if canonical in seen:
continue
seen.add(canonical)
edges.append({"i": canonical[0], "j": canonical[1], "value": value})
edges.sort(key=lambda item: abs(item["value"]), reverse=True)
return edges[:top_k]
def _influence_token_color(value: float, max_abs: float) -> str:
"""Gold-to-orange gradient for influence (non-negative) values."""
if max_abs <= 0:
return f"rgb({_NEUTRAL_RGB[0]}, {_NEUTRAL_RGB[1]}, {_NEUTRAL_RGB[2]})"
norm = min(1.0, value / max_abs)
# Light gold (255,244,210) -> deep orange (200,100,30)
r = int(255 - (255 - 200) * norm)
g = int(244 - (244 - 100) * norm)
b = int(210 - (210 - 30) * norm)
return f"rgb({r}, {g}, {b})"
def _influence_text_color(value: float, max_abs: float) -> str:
"""Text color for influence tokens based on background luminance."""
if max_abs <= 0:
return "#020617"
norm = min(1.0, value / max_abs)
r = int(255 - (255 - 200) * norm)
g = int(244 - (244 - 100) * norm)
b = int(210 - (210 - 30) * norm)
luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0
return "#ffffff" if luminance < 0.42 else "#020617"
def create_text_interaction_html(
features: List[str],
marginals: Any,
interactions: List[Dict[str, Any]],
*,
method: str = "shapley",
top_k: int = 20,
threshold: float = 0.0,
) -> str:
if not features:
return "<div class='text-interaction-empty'>No tokens available.</div>"
is_influence = method.lower() == "influence"
values = _coerce_marginals(features, marginals)
if is_influence:
values = [abs(v) for v in values]
max_abs = max((abs(v) for v in values), default=0.0)
edges = _sanitize_edges(interactions or [], len(features), top_k, threshold)
if is_influence:
for edge in edges:
edge["value"] = abs(edge["value"])
edge_note = "" if edges else "<div class='text-interaction-note'>No interactions to display.</div>"
method_label = (method or "score").strip().title()
influence_note = (
"<p class='text-interaction-legend-note' style='margin-top:4px;font-style:italic;'>"
"Influence scores are always non-negative &mdash; they represent squared Fourier "
"coefficients of the M&ouml;bius transform, measuring each token&#39;s contribution magnitude.</p>"
if is_influence else ""
)
view_id = f"text-interaction-{uuid.uuid4().hex[:8]}"
tokens_html = []
for idx, token in enumerate(features):
value = values[idx]
bg = _value_to_color(value, max_abs)
fg = _text_color_for_value(value, max_abs)
token_label = escape(_strip_occurrence_suffix(str(token)))
if is_influence:
tooltip = escape(f"{method_label} token {idx + 1}: {value:.4f}")
else:
tooltip = escape(f"{method_label} token {idx + 1}: {value:+.4f}")
tokens_html.append(
"<span "
f"class='text-interaction-token' "
f"id='tok-{idx}' data-index='{idx}' data-attr='{value:.6f}' "
f"style='background:{bg};color:{fg};' title='{tooltip}'>"
f"{token_label}"
"</span>"
)
payload = {
"edges": edges,
"max_abs": max((abs(edge["value"]) for edge in edges), default=0.0),
}
data_blob = json.dumps(payload)
displayed_edge_count = len(edges)
script_id = f"{view_id}-script"
loader_id = f"{view_id}-loader"
js_code = (
# layout constants for curved routing and hover focus
"const LANE_SPACING=20;" # separation between lanes
"const MAX_LANES=12;" # allow more lanes before shrinking spacing
"const EXTRA_FOR_DISTANCE=0.08;" # multiplies span in px for extra lift
"const MAX_ARC_EXTRA=90;" # cap extra lift based on distance
"const MAX_ARC_LIFT=160;" # cap total arc height
"const TOP_BASE=45;" # base lift for first top lane
"const BOTTOM_BASE=45;" # base drop for first bottom lane
"const TOP_MARGIN=16;" # extra safety margin
"const BOTTOM_MARGIN=16;"
"const TOKEN_BAND=42;" # estimated token row thickness
"const DEBUG_LANES=false;"
# ── getBestAnchors: rectangle-aware anchor selection ──────────────
# Each token chip is a rectangle. For every edge we pick the pair of
# border anchor points (left/right/top/bottom midpoints) that yields
# the shortest Euclidean distance, with a small penalty to discourage
# "bottom" anchors (which cause ugly under-loops) when better options
# exist.
"function _anchors(b){"
"return ["
"{x:b.right, y:b.cy, side:'right'},"
"{x:b.left, y:b.cy, side:'left'},"
"{x:b.cx, y:b.top, side:'top'},"
"{x:b.cx, y:b.bottom, side:'bottom'}"
"];}"
"function getBestAnchors(boxA,boxB){"
"const aa=_anchors(boxA),bb=_anchors(boxB);"
"let best=null,bestScore=Infinity;"
"for(const a of aa){for(const b of bb){"
"let dx=a.x-b.x,dy=a.y-b.y;"
"let d=Math.sqrt(dx*dx+dy*dy);"
# Small penalty for bottom anchors to avoid under-loops.
"if(a.side==='bottom')d+=18;"
"if(b.side==='bottom')d+=18;"
# Penalise paths that would cross through one of the chips:
# if anchor A is on the left of boxA but boxB is to the right (and
# vice-versa) – i.e. the path would have to go backward through A.
"if(a.side==='left' && b.x>boxA.cx)d+=30;"
"if(a.side==='right' && b.x<boxA.cx)d+=30;"
"if(b.side==='left' && a.x>boxB.cx)d+=30;"
"if(b.side==='right' && a.x<boxB.cx)d+=30;"
"if(d<bestScore){bestScore=d;best={start:a,end:b};}"
"}}return best;}"
# ── makeSmartPath: smooth curve between two anchor points ───────────
# Always draws a cubic Bézier so every edge is a smooth curve that
# exits / enters each chip border cleanly. Curvature (the "pull" of
# the control points away from the straight line) scales with both the
# pixel gap *and* the token-index distance, with a cap to prevent
# huge loops.
#
# pull formula:
# basePull = gap * lerp(0.10, 0.45, saturate(tokenDist / 12))
# pull = clamp(basePull, 6, 90)
#
# Very close tokens (dist 1, small gap) → pull ≈ 6–10 px (tiny bend)
# Medium distance (dist 4–6) → pull grows smoothly
# Far tokens (dist ≥ 12) → pull capped at 90 px
"function makeSmartPath(ax,ay,bx,by,sideA,sideB,tokenDist){"
"const dx=bx-ax,dy=by-ay;"
"const gap=Math.sqrt(dx*dx+dy*dy);"
# Distance-dependent curvature fraction: 0.10 for adjacent, 0.45 for far.
"const t=Math.min(1,Math.max(0,(tokenDist-1)/11));" # 0 at dist≤1, 1 at dist≥12
"const frac=0.10+0.35*t;"
"const pull=Math.max(6,Math.min(gap*frac,90));"
# Build per-side offset pushing the control point outward from chip.
"function off(side){"
"if(side==='left') return {x:-pull,y:0};"
"if(side==='right') return {x: pull,y:0};"
"if(side==='top') return {x:0,y:-pull};"
"return {x:0,y:pull};}" # bottom
"const oA=off(sideA),oB=off(sideB);"
# Always cubic Bézier: two control points ensure the curve leaves
# each chip at the correct angle regardless of side combination.
"const c1x=ax+oA.x,c1y=ay+oA.y,c2x=bx+oB.x,c2y=by+oB.y;"
"return `M ${ax.toFixed(1)},${ay.toFixed(1)} C ${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${bx.toFixed(1)},${by.toFixed(1)}`;}"
f"const root=document.getElementById('{view_id}');"
"if(!root){return;}"
f"const data={data_blob};"
"const edges=data.edges||[];"
"const maxAbs=data.max_abs||0;"
"const wrapper=root.querySelector('.text-interaction-wrapper');"
"const svg=root.querySelector('.text-interaction-edges');"
"const tokens=[...root.querySelectorAll('.text-interaction-token')];"
"const edgeEls=[];"
"const adjacency=new Map();"
"let hoverToken=null;"
"const tokenMap=new Map(tokens.map(el=>[el.dataset.index,el]));"
"function trackAdj(a,b){"
"if(!adjacency.has(a))adjacency.set(a,new Set());"
"adjacency.get(a).add(b);"
"}"
"function applyHover(){"
"const focus=hoverToken;"
"const focusKey=focus===null?null:String(focus);"
"if(focusKey===null){"
"tokens.forEach(t=>t.classList.remove('token-dim','token-focus'));"
"edgeEls.forEach(e=>{e.classList.remove('edge-dim','edge-focus');});"
"return;"
"}"
"const neighbors=adjacency.get(focusKey)||new Set();"
"tokens.forEach(t=>{"
"const idx=t.dataset.index;"
"const active=idx===focusKey||neighbors.has(idx);"
"t.classList.toggle('token-focus',active);"
"t.classList.toggle('token-dim',!active);"
"});"
"edgeEls.forEach(e=>{"
"const active=e.dataset.start===focusKey||e.dataset.end===focusKey;"
"e.classList.toggle('edge-focus',active);"
"e.classList.toggle('edge-dim',!active);"
"});"
"}"
"function measureBoxes(){"
"const wrap=wrapper.getBoundingClientRect();"
"const out={};"
"tokens.forEach(el=>{"
"const box=el.getBoundingClientRect();"
"const left=box.left-wrap.left;"
"const top=box.top-wrap.top;"
"const w=box.width;"
"const h=box.height;"
"out[el.dataset.index]={"
"left:left,right:left+w,top:top,bottom:top+h,"
"cx:left+w/2,cy:top+h/2,w:w,h:h};"
"});"
"return out;"
"}"
"function lanePack(edgeList){"
"edgeList.sort((a,b)=>{"
"const spanA=a.endX-a.startX;"
"const spanB=b.endX-b.startX;"
"if(spanA!==spanB){return spanB-spanA;}"
"if(a.startX!==b.startX){return a.startX-b.startX;}"
"return a.endX-b.endX;});"
"const lanes=[];"
"edgeList.forEach(edge=>{"
"let lane=0; let placed=false;"
"for(; lane<lanes.length; lane++){"
"let overlap=false;"
"for(const iv of lanes[lane]){"
"if(!(edge.endX < iv.startX || edge.startX > iv.endX)){overlap=true;break;}"
"}"
"if(!overlap){edge.lane=lane;lanes[lane].push(edge);placed=true;break;}"
"}"
"if(!placed){edge.lane=lanes.length;lanes.push([edge]);}"
"});"
"return lanes.length||1;"
"}"
"function draw(){"
"let rect=wrapper.getBoundingClientRect();"
"if(rect.width===0||rect.height===0){return;}"
"while(svg.firstChild){svg.removeChild(svg.firstChild);}edgeEls.length=0;"
"adjacency.clear();"
"let boxes=measureBoxes();"
"const clipId='ti-clip-" f"{view_id}" "';"
"let defs=svg.querySelector('defs');"
"if(!defs){defs=document.createElementNS('http://www.w3.org/2000/svg','defs');svg.appendChild(defs);}"
"let clip=defs.querySelector(`#${clipId}`);"
"if(!clip){clip=document.createElementNS('http://www.w3.org/2000/svg','clipPath');clip.setAttribute('id',clipId);defs.appendChild(clip);}"
"let clipRect=clip.querySelector('rect');"
"if(!clipRect){clipRect=document.createElementNS('http://www.w3.org/2000/svg','rect');clip.appendChild(clipRect);}"
"clipRect.setAttribute('x','0');clipRect.setAttribute('y','0');"
"clipRect.setAttribute('width',rect.width);clipRect.setAttribute('height',rect.height);"
"let g=svg.querySelector('g.interaction-edges-group');"
"if(!g){g=document.createElementNS('http://www.w3.org/2000/svg','g');g.classList.add('interaction-edges-group');svg.appendChild(g);}"
"g.setAttribute('clip-path',`url(#${clipId})`);"
"while(g.firstChild){g.removeChild(g.firstChild);}"
"const posEdges=[], negEdges=[];"
"edges.forEach(edge=>{"
"const bA=boxes[String(edge.i)];"
"const bB=boxes[String(edge.j)];"
"if(!bA||!bB){return;}"
# Compute token distance (index difference) for path strategy.
"const tokenDist=Math.abs(edge.j-edge.i);"
"const rec=Object.assign({"
"startX:Math.min(bA.cx,bB.cx),"
"endX:Math.max(bA.cx,bB.cx),"
"spanPx:Math.abs(bA.cx-bB.cx),"
"tokenDist:tokenDist"
"}, edge);"
"(edge.value>=0?posEdges:negEdges).push(rec);"
"});"
"const posLaneCount=lanePack(posEdges);"
"const negLaneCount=lanePack(negEdges);"
"const clampExtra=(span)=>Math.min(span*EXTRA_FOR_DISTANCE,MAX_ARC_EXTRA);"
"function laneSpacing(count){"
"if(count<=8){return LANE_SPACING;}"
"return Math.max(10, LANE_SPACING*8/count);"
"}"
"const posSpacing=laneSpacing(posLaneCount);"
"const negSpacing=laneSpacing(negLaneCount);"
"const maxPosLane=posEdges.reduce((m,e)=>Math.max(m,e.lane||0),-1);"
"const maxNegLane=negEdges.reduce((m,e)=>Math.max(m,e.lane||0),-1);"
"const maxPosExtra=posEdges.reduce((m,e)=>Math.max(m,clampExtra(e.spanPx||0)),0);"
"const maxNegExtra=negEdges.reduce((m,e)=>Math.max(m,clampExtra(e.spanPx||0)),0);"
"const topNeeded=TOP_BASE + maxPosExtra + Math.max(0,maxPosLane+1)*posSpacing + TOP_MARGIN;"
"const bottomNeeded=BOTTOM_BASE + maxNegExtra + Math.max(0,maxNegLane+1)*negSpacing + BOTTOM_MARGIN;"
"const padTop=Math.max(24, topNeeded);"
"const padBottom=Math.max(24, bottomNeeded);"
"const desiredHeight=padTop + padBottom + TOKEN_BAND;"
"wrapper.style.paddingTop=`${padTop}px`;"
"wrapper.style.paddingBottom=`${padBottom}px`;"
"wrapper.style.minHeight=`${desiredHeight}px`;"
"rect=wrapper.getBoundingClientRect();"
"boxes=measureBoxes();"
"const tokenYs2=Object.values(boxes).map(c=>c.cy);"
"const tokenRowY2=tokenYs2.length?tokenYs2.reduce((a,b)=>a+b,0)/tokenYs2.length:rect.height/2;"
"svg.setAttribute('width',rect.width);"
"svg.setAttribute('height',rect.height);"
"svg.setAttribute('viewBox',`0 0 ${rect.width} ${rect.height}`);"
"clipRect.setAttribute('width',rect.width);clipRect.setAttribute('height',rect.height);"
"const renderList=posEdges.concat(negEdges);"
"renderList.forEach(edge=>{"
"const bA=boxes[String(edge.i)];"
"const bB=boxes[String(edge.j)];"
"if(!bA||!bB){return;}"
"const labelA=(tokenMap.get(String(edge.i))||{}).textContent||'';"
"const labelB=(tokenMap.get(String(edge.j))||{}).textContent||'';"
"const tokenDist=edge.tokenDist||Math.abs(edge.j-edge.i);"
# Pick the best anchor points on the chip borders.
"const anchors=getBestAnchors(bA,bB);"
"if(!anchors){return;}"
"const pathD=makeSmartPath(anchors.start.x,anchors.start.y,"
"anchors.end.x,anchors.end.y,anchors.start.side,anchors.end.side,tokenDist);"
"if(DEBUG_LANES){"
"console.log({i:edge.i,j:edge.j,sideA:anchors.start.side,sideB:anchors.end.side,tokenDist,lane:edge.lane});"
"}"
"const path=document.createElementNS('http://www.w3.org/2000/svg','path');"
"path.setAttribute('d',pathD);"
"const norm=maxAbs?Math.min(1,Math.abs(edge.value)/maxAbs):0;"
"const width=1+5*norm;"
"const opacity=0.25+0.55*norm;"
"const color=edge.value>=0?'#4a1c87':'#dd1313';"
"path.setAttribute('stroke',color);"
"path.setAttribute('stroke-width',width.toFixed(2));"
"path.setAttribute('opacity',opacity.toFixed(2));"
"path.setAttribute('stroke-linecap','round');"
"path.setAttribute('stroke-linejoin','round');"
"path.classList.add('interaction-edge');"
"path.style.setProperty('--edge-width',width.toFixed(2));"
"path.style.setProperty('--edge-opacity',opacity.toFixed(2));"
"path.dataset.start=String(edge.i);"
"path.dataset.end=String(edge.j);"
"trackAdj(path.dataset.start,path.dataset.end);"
"trackAdj(path.dataset.end,path.dataset.start);"
"const title=document.createElementNS('http://www.w3.org/2000/svg','title');"
"title.textContent=`${labelA} x ${labelB} : ${edge.value.toFixed(3)}`;"
"path.appendChild(title);"
"g.appendChild(path);edgeEls.push(path);"
"});"
"applyHover();"
"}"
"tokens.forEach(token=>{"
"token.addEventListener('mouseenter',()=>{"
"hoverToken=token.dataset.index;"
"applyHover();"
"});"
"token.addEventListener('mouseleave',()=>{"
"hoverToken=null;"
"applyHover();"
"});"
"});"
"const schedule=()=>{window.requestAnimationFrame(draw);};"
"schedule();"
"if(window.ResizeObserver){"
"const ro=new ResizeObserver(schedule);ro.observe(wrapper);"
"}else{window.addEventListener('resize',schedule);}"
"if(document.fonts&&document.fonts.ready){document.fonts.ready.then(schedule);}"
)
return (
"<style>"
".text-interaction-root{--ti-bg:#f7f5f2;--ti-border:#e3e3ec;"
"--ti-card-bg:#ffffff;--ti-card-shadow:0 14px 30px rgba(32,25,40,0.08);"
"--ti-text:#3d2c36;--ti-top-pad:90px;--ti-bottom-pad:90px;"
"--ti-min-height:260px;position:relative;font-family:'Segoe UI','Helvetica Neue',"
"Arial,sans-serif;background:var(--ti-bg);border:1px solid var(--ti-border);"
"border-radius:18px;padding:20px;display:flex;flex-wrap:wrap;gap:18px;}"
".text-interaction-card{flex:3 1 520px;background:var(--ti-card-bg);"
"border:1px solid var(--ti-border);border-radius:18px;padding:18px;"
"box-shadow:var(--ti-card-shadow);}"
".text-interaction-header{display:flex;justify-content:space-between;"
"align-items:flex-end;margin-bottom:12px;gap:8px;}"
".text-interaction-title{font-size:18px;font-weight:600;color:var(--ti-text);}"
".text-interaction-subtitle{font-size:13px;color:#7f6f86;}"
".text-interaction-wrapper{position:relative;padding:var(--ti-top-pad) 8px var(--ti-bottom-pad);"
"min-height:var(--ti-min-height);box-sizing:border-box;}"
".text-interaction-tokens{display:flex;flex-wrap:wrap;gap:8px;z-index:2;"
"position:relative;}"
".text-interaction-token{display:inline-flex;align-items:center;justify-content:center;"
"padding:6px 12px;border-radius:12px;font-size:13px;font-weight:600;"
"color:#2a2140;border:1px solid rgba(60,44,80,0.15);"
"box-shadow:0 2px 6px rgba(50,40,70,0.06);transition:all .2s ease;"
"white-space:pre-wrap;}"
".text-interaction-token:hover{border-color:#3d2c36;}"
".text-interaction-token.token-focus{outline:none;border-color:#3d2c36;"
"box-shadow:0 8px 16px rgba(32,25,40,0.14);}"
".text-interaction-token.token-dim{opacity:0.42;filter:saturate(0.55);}"
".text-interaction-edges{position:absolute;left:0;top:0;width:100%;height:100%;"
"z-index:1;pointer-events:none;overflow:hidden;}"
".interaction-edge{fill:none;pointer-events:none;transition:opacity .18s ease,stroke-width .18s ease;"
"stroke-linecap:round;stroke-linejoin:round;stroke-width:var(--edge-width,2px);"
"opacity:var(--edge-opacity,0.4);}"
".interaction-edge.edge-focus{opacity:1 !important;stroke-width:calc(var(--edge-width,2px) + 1.5px);}"
".interaction-edge.edge-dim{opacity:0.12 !important;}"
".text-interaction-note{font-size:12px;color:#7b6f91;margin-top:8px;}"
".text-interaction-side-panel{flex:1 1 220px;display:flex;flex-direction:column;"
"gap:12px;}"
".text-interaction-side-card{background:#fefcf8;border:1px dashed var(--ti-border);"
"border-radius:16px;padding:16px;}"
".text-interaction-side-card strong{display:block;font-size:15px;color:var(--ti-text);"
"margin-bottom:6px;}"
".text-interaction-legend-bar{display:flex;align-items:center;gap:8px;"
"margin:12px 0;}"
".text-interaction-legend-label{font-size:12px;color:#6f5a72;text-transform:uppercase;"
"letter-spacing:0.08em;}"
".text-interaction-legend-gradient{flex:1;height:10px;border-radius:999px;"
"background:linear-gradient(90deg,#dd1313,#d8c6f0,#4a1c87);}"
".text-interaction-legend-note{font-size:12px;color:#6f5a72;margin:0;}"
".text-interaction-loader{position:absolute;width:0;height:0;overflow:hidden;}"
"@media (prefers-color-scheme: dark){"
".text-interaction-root{--ti-bg:#0f1522;--ti-border:#2c3950;--ti-card-bg:#152033;"
"--ti-card-shadow:0 18px 36px rgba(0,0,0,0.35);--ti-text:#edf2ff;}"
".text-interaction-subtitle,.text-interaction-note,.text-interaction-legend-label,"
".text-interaction-legend-note{color:#a9b6cb;}"
".text-interaction-token,.text-interaction-token *{color:#020617 !important;}"
".text-interaction-token{border-color:rgba(184,198,219,0.2);"
"box-shadow:0 4px 14px rgba(0,0,0,0.2);}"
".text-interaction-token:hover,.text-interaction-token.token-focus{border-color:#d8e1ff;}"
".text-interaction-side-card{background:#111a2b;border-color:#33435f;}"
".text-interaction-title{color:#edf2ff;}"
"}"
"@media (max-width: 900px){"
".text-interaction-card,.text-interaction-side-panel{flex:1 1 100%;}}"
"</style>"
f"<div class='text-interaction-root' id='{view_id}'>"
"<div class='text-interaction-card'>"
"<div class='text-interaction-header'>"
"<div>"
"<div class='text-interaction-title'>Text Interaction View</div>"
f"<div class='text-interaction-subtitle'>Tokens: {len(features)}</div>"
"</div>"
f"<div class='text-interaction-subtitle'>Top-{displayed_edge_count} interactions shown; adjust threshold to declutter.</div>"
"</div>"
"<div class='text-interaction-wrapper'>"
"<svg class='text-interaction-edges' aria-hidden='true'></svg>"
f"<div class='text-interaction-tokens'>{''.join(tokens_html)}</div>"
"</div>"
f"{edge_note}"
"</div>"
"<div class='text-interaction-side-panel'>"
"<div class='text-interaction-side-card'>"
f"<strong>{method_label} legend</strong>"
"<div class='text-interaction-legend-bar'>"
f"<span class='text-interaction-legend-label'>{'Low' if is_influence else 'Negative'}</span>"
"<div class='text-interaction-legend-gradient'></div>"
f"<span class='text-interaction-legend-label'>{'High' if is_influence else 'Positive'}</span>"
"</div>"
f"<p class='text-interaction-legend-note'>Normalized by max |value| = {max_abs:.4f}. "
"Hover tokens for exact scores.</p>"
f"{influence_note}"
"</div>"
"</div>"
f"<img class='text-interaction-loader' id='{loader_id}' alt='' "
"src='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' "
f"onload=\"(function(){{var s=document.getElementById('{script_id}');"
"if(!s||!s.textContent){return;}try{(new Function(s.textContent))();}catch(e){"
"console.warn('text interaction init failed',e);}})()\" />"
f"<script type='text/plain' id='{script_id}'>{js_code}</script>"
"</div>"
)