import { D3Sel, calculateSurprisal, calculateSurprisalDensity } from "../core/Util"; import { SimpleEventHandler } from "../core/SimpleEventHandler"; import { GLTR_RenderItem } from "./GLTR_Text_Box"; import type { FrontendToken } from "../../shared/api/GLTR_API"; import * as d3 from "d3"; import { tr } from "../lang/i18n-lite"; import { getTokenRenderStyle } from "../cross/tokenRenderStyle"; import { tooltipTokenDisplayHtml } from "../cross/tokenDisplayUtils"; import { buildTooltipPredictionsInnerHtml, getFrontendTokenTopkState, } from '../cross/tooltipPredictionsFromToken'; const SEPARATOR = '─────────────'; /** 贴在定位包含块角落时的留白(px);{@link ToolTipOptions.placement} `parent-bottom-right` 使用 */ const CORNER_INSET_PX = 0; export type ToolTipOptions = { /** 真实 top-k 下 surprisal 行的标签(默认「信息量」) */ surprisalRowLabel?: string; /** * `parent-bottom-right`:`position:absolute`,贴在定位包含块(`offsetParent`)右下角(DAG Top‑K 作为 `#results` 直接子节点时即为 results 内侧右下)。 * 该 HUD 模式不依赖锚点几何;面板应由页面 CSS(如 `.gen-attr-dag-topk-tooltip`)约束宽高,超出部分裁剪。 * 默认 `anchor`:沿用原有相对 token rect 的定位。 */ placement?: 'anchor' | 'parent-bottom-right'; /** * false:面板不参与命中测试(`pointer-events: none`),避免盖住底层 SVG 时在节点上反复 `mouseleave`/闪动; * 同时不注册点击/触摸收起。 */ pointerInteractive?: boolean; }; /** {@link ToolTip.updateData} 可选增补(如 DAG:CI/MI 行紧跟 surprisal 之后) */ export type ToolTipUpdateAugment = { /** 在 surprisal / 信息密度行之前渲染(紧跟 token 文字,位于所有 info 行上方) */ rowsBeforeInfo?: Array<{ label: string; value: string; valueColor?: boolean }>; rowsAfterSurprisal?: Array<{ label: string; value: string; valueColor?: boolean }>; }; type DetailField = { label: string; value: string; valueColor?: boolean }; function renderField(f: DetailField, dc: string, vc: string): string { const valColor = f.valueColor !== false ? vc : dc; return `${f.label} ${f.value}`; } export class ToolTip { private predictions: D3Sel; private myDetail: D3Sel; private currentToken: D3Sel; // 缓存:d3 formatter(静态,可永久缓存) private readonly numF = d3.format('.3f'); private readonly significantF = d3.format('.3g'); // 缓存:主题颜色(Top-K 表格行已改由 CSS 变量,见 tooltipPredictionsFromToken / topkChartUtils / .predictions-table) private themeColors = { selectedColor: '#933', detailColor: '#666666', valueColor: '#333' }; // 防抖:pending 的更新任务 private pendingUpdate: number | null = null; private pendingData: { ri: GLTR_RenderItem; anchorTarget: EventTarget | null; augment?: ToolTipUpdateAugment; } | null = null; // 主题监听器 private themeObserver: MutationObserver | null = null; private readonly surprisalRowLabel: string; private readonly placement: NonNullable; private readonly pointerInteractive: boolean; constructor(private parent: D3Sel, private eh: SimpleEventHandler, options?: ToolTipOptions) { this.surprisalRowLabel = options?.surprisalRowLabel ?? tr('information:'); this.placement = options?.placement ?? 'anchor'; this.pointerInteractive = options?.pointerInteractive ?? true; if (!this.pointerInteractive) { this.parent.classed('tooltip-no-pointer-hit', true); } const el = this.parent.node() as HTMLElement | null; if (el && this.placement === 'parent-bottom-right') { el.style.position = 'absolute'; } this._init(); this._setupThemeObserver(); this._updateThemeColors(); } private _init() { this.predictions = this.parent.select('.predictions'); this.myDetail = this.parent.select('.myDetail'); this.currentToken = this.parent.select('.currentToken'); if (this.pointerInteractive) { this.parent.on('click', (event) => { event.stopPropagation(); event.preventDefault(); this.visibility = false; }); this.parent.on('touchstart', (event) => { event.stopPropagation(); event.preventDefault(); this.visibility = false; }); } } /** * 设置主题变化监听器 */ private _setupThemeObserver(): void { // 监听 document.documentElement 的 data-theme 属性变化 this.themeObserver = new MutationObserver(() => { this._updateThemeColors(); }); this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); } /** * 更新主题颜色缓存 */ private _updateThemeColors(): void { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; // 从 CSS 变量获取文本颜色(如果可用,否则使用默认值) const textColorLight = getComputedStyle(document.documentElement) .getPropertyValue('--text-color-light') .trim() || '#e0e0e0'; this.themeColors = { selectedColor: isDark ? '#ff6666' : '#933', detailColor: isDark ? '#888' : '#666666', valueColor: isDark ? textColorLight : '#333' }; } /** * 获取真实的可见视口尺寸和偏移量 * 优先使用 visualViewport API(解决 iOS Safari 地址栏动态显示/隐藏问题) * 备选方案:使用 document.documentElement.clientHeight(相对稳定) */ private _getViewportInfo(): { width: number; height: number; offsetTop: number; offsetLeft: number; } { // 优先使用 visualViewport API(iOS Safari 13+, Chrome 61+) if (window.visualViewport) { return { width: window.visualViewport.width, height: window.visualViewport.height, offsetTop: window.visualViewport.offsetTop || 0, offsetLeft: window.visualViewport.offsetLeft || 0 }; } // 备选方案:使用 document.documentElement.clientHeight // 这个值相对稳定,不受地址栏影响 return { width: window.innerWidth, height: document.documentElement.clientHeight || window.innerHeight, offsetTop: 0, offsetLeft: 0 }; } /** * 从事件目标解析用于定位的 SVG rect(与指针坐标无关):rect 自身、或容器 `g` 内首个 `rect`、或向上追溯。 */ private _resolveAnchorRectElement(target: EventTarget | null): SVGRectElement | null { if (!target || !(target instanceof Element)) return null; let element: Element | null = target; if (element instanceof SVGRectElement) return element; if (element instanceof SVGGElement) { return element.querySelector('rect'); } while (element) { if (element instanceof SVGRectElement) return element; if (element instanceof SVGGElement) { const r = element.querySelector('rect'); if (r) return r; } element = element.parentElement; } return null; } /** * 清理资源 */ dispose(): void { if (this.themeObserver) { this.themeObserver.disconnect(); this.themeObserver = null; } if (this.pendingUpdate !== null) { cancelAnimationFrame(this.pendingUpdate); this.pendingUpdate = null; } } /** * 隐藏并重置位置,避免残留的绝对定位撑高容器 */ hideAndReset(): void { const node = this.parent.node() as HTMLElement | null; if (this.pendingUpdate !== null) { cancelAnimationFrame(this.pendingUpdate); this.pendingUpdate = null; } this.pendingData = null; this.visibility = false; if (node) { if (this.placement === 'parent-bottom-right') { this._placeParentBottomRight(node); } else { node.style.top = '0px'; node.style.left = '0px'; } } } /** 固定在 offsetParent(含隐藏态占位)右下角 */ private _placeParentBottomRight(node: HTMLElement): void { node.style.position = 'absolute'; node.style.right = `${CORNER_INSET_PX}px`; node.style.left = 'auto'; node.style.top = 'auto'; node.style.bottom = `${CORNER_INSET_PX}px`; } set visibility(vis: boolean) { const node = this.parent.node() as HTMLElement | null; if (vis == true) { node?.classList.add('tooltip-visible'); node?.style.removeProperty('opacity'); this.parent.style('pointer-events', this.pointerInteractive ? 'auto' : 'none'); } else { node?.classList.remove('tooltip-visible'); this.parent.style('opacity', 0); this.parent.style('pointer-events', 'none'); // 关闭时禁止点击,让事件穿透 } } /** * @param eventOrAnchor 指针事件(使用 `target`)或直接传入用作锚点的元素(如 SVG `rect` / `g`) */ updateData( ri: GLTR_RenderItem, eventOrAnchor?: MouseEvent | TouchEvent | Element | null, augment?: ToolTipUpdateAugment ) { // 防抖:取消之前的更新任务 if (this.pendingUpdate !== null) { cancelAnimationFrame(this.pendingUpdate); } const anchorTarget = eventOrAnchor instanceof Element ? eventOrAnchor : eventOrAnchor && 'target' in eventOrAnchor ? (eventOrAnchor.target as EventTarget | null) : null; // 保存最新的数据 this.pendingData = { ri, anchorTarget, augment }; // 先将 tooltip 移到屏幕外,避免在位置计算完成前显示在旧位置 // 这可以解决 iOS Safari 上触摸时的抖动问题: // 如果旧位置在触摸点下方,会触发 tooltip 的 touchstart 导致关闭 const node = this.parent.node() as HTMLElement; if (node) { if (this.placement === 'parent-bottom-right') { this._placeParentBottomRight(node); } else { node.style.left = '-9999px'; } } this.visibility = true; // 使用 requestAnimationFrame 同时处理内容更新和位置计算 this.pendingUpdate = requestAnimationFrame(() => { this.pendingUpdate = null; if (!this.pendingData) return; const { ri: currentRi, anchorTarget: at, augment } = this.pendingData; this.pendingData = null; // 更新内容 this._updateContent(currentRi, augment); // 立即计算位置(DOM已更新,getBoundingClientRect 能获取准确值) this._updatePosition(at); }); } /** * 更新tooltip内容 * 统一结构:语义区块(上) + 分隔线 + 信息密度区块(下,含汇总指标 + top-k 表格) */ private _updateContent(ri: GLTR_RenderItem, augment?: ToolTipUpdateAugment): void { const { selectedColor, detailColor, valueColor } = this.themeColors; // 更新当前token显示(第一行) this.currentToken.html(() => { const visualizedToken = tooltipTokenDisplayHtml(ri.tokenData.raw); return `${visualizedToken}`; }); const tokenData = ri.tokenData as FrontendToken; const s = ri.semantic; const hasSemantic = s && (s.pwScore !== undefined || s.signalProb !== undefined || s.rawScoreNormed !== undefined || s.rawScore !== undefined || (s.chunkIndex !== undefined && s.chunkMatchDegree !== undefined)); const { hasRealTopk } = getFrontendTokenTopkState(tokenData); // 1. 构建上区块:语义行 + rowsBeforeInfo(DAG 归因份额等附加行) // 二者在视觉上同属"token 语义信息",与下方信息密度区块以分隔线隔开 const topRows: string[] = []; if (hasSemantic && s) { if (s.pwScore !== undefined) topRows.push(renderField({ label: tr('pw score:'), value: this.numF(s.pwScore) }, detailColor, valueColor)); if (s.signalProb !== undefined) topRows.push(renderField({ label: tr('signal probability:'), value: this.numF(s.signalProb) }, detailColor, valueColor)); if (s.rawScoreNormed !== undefined) topRows.push(renderField({ label: tr('raw score normed:'), value: this.numF(s.rawScoreNormed) }, detailColor, valueColor)); if (s.rawScore !== undefined) topRows.push(renderField({ label: tr('raw score:'), value: d3.format('.6f')(s.rawScore), valueColor: false }, detailColor, valueColor)); if (s.chunkIndex !== undefined && s.chunkMatchDegree !== undefined) { topRows.push(renderField({ label: `chunk #${s.chunkIndex} match score:`, value: (s.chunkMatchDegree * 100).toFixed(1) + '%' }, detailColor, valueColor)); } } for (const f of augment?.rowsBeforeInfo ?? []) { topRows.push(renderField(f, detailColor, valueColor)); } // 2. 构建信息密度区块:按明确顺序追加,避免脆弱的 unshift/splice const infoRows: string[] = []; if (hasRealTopk) { const prob = tokenData.real_topk![1]; const surprisal = calculateSurprisal(prob); const isClassic = getTokenRenderStyle() === 'classic'; if (!isClassic) { const informationDensity = calculateSurprisalDensity(tokenData); const utf8Size = new TextEncoder().encode(tokenData.raw).length; infoRows.push(renderField({ label: tr('information density:'), value: `${this.significantF(informationDensity)} ${tr('bits/Byte')}` }, detailColor, valueColor)); infoRows.push(renderField({ label: tr('UTF-8 size:'), value: `${utf8Size} ${tr('bytes')}`, valueColor: false }, detailColor, valueColor)); } infoRows.push(renderField({ label: this.surprisalRowLabel, value: `${this.significantF(surprisal)} bits` }, detailColor, valueColor)); } for (const f of augment?.rowsAfterSurprisal ?? []) { infoRows.push(renderField(f, detailColor, valueColor)); } // 3. 合并 myDetail:上区块 + 分隔线(仅当两区块都有时) + 信息密度区块 const detailParts: string[] = []; if (topRows.length) detailParts.push(topRows.join('
')); if (topRows.length && infoRows.length) detailParts.push(`${SEPARATOR}`); if (infoRows.length) detailParts.push(infoRows.join('
')); this.myDetail.html(detailParts.join('
')); // 4. 更新 predictions(top-k 属于信息密度区块,与 buildTooltipPredictionsInnerHtml 共用逻辑) const predInner = buildTooltipPredictionsInnerHtml(tokenData); if (predInner === '') { this.predictions.selectAll('.row').data([]).join('div').remove(); } else { this.predictions.html(predInner); } } /** * 更新tooltip位置(相对锚点元素几何,不依赖指针坐标) */ private _updatePosition(anchorTarget: EventTarget | null): void { const tooltipNode = this.parent.node() as HTMLElement; if (!tooltipNode) return; if (this.placement === 'parent-bottom-right') { this._placeParentBottomRight(tooltipNode); return; } // 获取视口信息(用于边界检查) const viewport = this._getViewportInfo(); // fixed:相对视口;absolute:相对 offsetParent(首页 #results、归因侧栏 #attribution_panel_results 等), // 不可写死 #results,否则侧栏 tooltip 的 left/top 会按主栏计算而跑出可视区。 const isFixedPosition = window.getComputedStyle(tooltipNode).position === 'fixed'; let anchorRect: { left: number; top: number; width: number; height: number }; if (isFixedPosition) { anchorRect = { left: 0, top: 0, width: viewport.width, height: viewport.height, }; } else { const anchor = tooltipNode.offsetParent as HTMLElement | null; if (!anchor) { throw new Error( '[ToolTip] position:absolute 的 tooltip 必须有 offsetParent(请为祖先设置 position 等定位上下文)' ); } anchorRect = anchor.getBoundingClientRect(); } const tokenRectElement = this._resolveAnchorRectElement(anchorTarget); if (!tokenRectElement) { throw new Error( '[ToolTip] 无法从锚点解析到 SVG rect,请传入 token rect、g 或其它含 rect 的祖先元素' ); } let tokenLeft = 0, tokenRight = 0, tokenTop = 0, tokenBottom = 0; const tokenRect = tokenRectElement.getBoundingClientRect(); if (isFixedPosition) { tokenLeft = tokenRect.left; tokenRight = tokenRect.right; tokenTop = tokenRect.top; tokenBottom = tokenRect.bottom; } else { tokenLeft = tokenRect.left - anchorRect.left; tokenRight = tokenRect.right - anchorRect.left; tokenTop = tokenRect.top - anchorRect.top; tokenBottom = tokenRect.bottom - anchorRect.top; } // 获取tooltip尺寸 const tooltipRect = tooltipNode.getBoundingClientRect(); const tooltipWidth = tooltipRect.width || 250; const tooltipHeight = tooltipRect.height || 100; const offset = 15; // 统一偏移量 // 计算初始位置(token右下方) let x = tokenRight + offset; let y = tokenBottom + offset; // 水平方向边界检查 const containerWidth = isFixedPosition ? viewport.width : anchorRect.width; if (x + tooltipWidth > containerWidth) { const leftX = tokenLeft - tooltipWidth - offset; x = leftX >= 5 ? leftX : containerWidth - tooltipWidth - 5; } // 垂直方向边界检查 if (isFixedPosition) { if (y + tooltipHeight > viewport.height) { y = Math.max(5, tokenTop - tooltipHeight - offset); } } else { const yInViewport = y + anchorRect.top; if (yInViewport + tooltipHeight > viewport.height) { y = tokenTop - tooltipHeight - offset; const yTopInViewport = y + anchorRect.top; if (yTopInViewport < 0) { y = -anchorRect.top + 5; } } } // 应用位置 this.parent.styles({ top: y + 'px', left: x + 'px', }); } }