InfoLens / client /src /shared /vis /ToolTip.ts
dqy08's picture
反向传播动画改进
ca74a9e
Raw
History Blame Contribute Delete
20.1 kB
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 `<span style="color: ${dc}">${f.label}</span> <span style="color: ${valColor}">${f.value}</span>`;
}
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<ToolTipOptions['placement']>;
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 `<span style="color: ${selectedColor};">${visualizedToken}</span>`;
});
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('<br/>'));
if (topRows.length && infoRows.length) detailParts.push(`<span style="color:${detailColor}">${SEPARATOR}</span>`);
if (infoRows.length) detailParts.push(infoRows.join('<br/>'));
this.myDetail.html(detailParts.join('<br/>'));
// 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',
});
}
}