Spaces:
Sleeping
Sleeping
| 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]], | |
| *, | |
| top_k: int = 20, | |
| threshold: float = 0.0, | |
| method: str = "shapley", | |
| ) -> 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.title() | |
| influence_note = ( | |
| "<p class='text-interaction-legend-note' style='margin-top:4px;font-style:italic;'>" | |
| "Influence scores are always non-negative — they represent squared Fourier " | |
| "coefficients of the Möbius transform, measuring each token'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>" | |
| ) | |