| 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 |
| |
| 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) |
| |
| 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 — 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 = ( |
| |
| "const LANE_SPACING=20;" |
| "const MAX_LANES=12;" |
| "const EXTRA_FOR_DISTANCE=0.08;" |
| "const MAX_ARC_EXTRA=90;" |
| "const MAX_ARC_LIFT=160;" |
| "const TOP_BASE=45;" |
| "const BOTTOM_BASE=45;" |
| "const TOP_MARGIN=16;" |
| "const BOTTOM_MARGIN=16;" |
| "const TOKEN_BAND=42;" |
| "const DEBUG_LANES=false;" |
| |
| |
| |
| |
| |
| |
| "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);" |
| |
| "if(a.side==='bottom')d+=18;" |
| "if(b.side==='bottom')d+=18;" |
| |
| |
| |
| "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;}" |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "function makeSmartPath(ax,ay,bx,by,sideA,sideB,tokenDist){" |
| "const dx=bx-ax,dy=by-ay;" |
| "const gap=Math.sqrt(dx*dx+dy*dy);" |
| |
| "const t=Math.min(1,Math.max(0,(tokenDist-1)/11));" |
| "const frac=0.10+0.35*t;" |
| "const pull=Math.max(6,Math.min(gap*frac,90));" |
| |
| "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};}" |
| "const oA=off(sideA),oB=off(sideB);" |
| |
| |
| "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;}" |
| |
| "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);" |
| |
| "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>" |
| ) |
|
|