InfoLens / client /src /ts /vis /SvgOverlayManager.ts
dqy08's picture
initial beta release
494c9e4
/**
* SVG覆盖层管理器
* 负责创建和管理SVG覆盖层,包括rect元素的创建、位置更新等
*/
import {FrontendAnalyzeResult} from "../api/GLTR_API";
import {calculateSurprisal, calculateSurprisalDensity} from "../utils/Util";
import {getByteSurprisalColor, getTokenSurprisalColor, getDiffColor, getSemanticSimilarityColor} from "../utils/SurprisalColorConfig";
import {TokenFragmentRect, RectCacheEntry, ZERO_WIDTH_FRAGMENT_PLACEHOLDER_PX} from "./types";
/** 差分模式配置 */
export interface DiffOverlayOptions {
enabled: boolean;
deltaByteSurprisals: number[];
charToByteIndexMap: number[];
}
/** 语义分析模式配置 */
export interface SemanticOverlayOptions {
analysisMode: boolean;
/** 查询匹配时每 token 的 raw score normed [0,1] */
rawScoresNormed?: number[];
}
export interface SvgOverlayManagerOptions {
/** 获取token的真实概率信息 */
getTokenRealTopk: (rd: FrontendAnalyzeResult, tokenIndex: number) => [number, number] | undefined;
/** 添加token事件监听器 */
addTokenEventListeners: (element: SVGGElement, tokenIndex: number, rd: FrontendAnalyzeResult) => void;
/** 染色方式:density=信息密度(bits/Byte),classic=token信息量(bits)。默认 classic */
tokenRenderStyle?: 'density' | 'classic';
/** 为 true 时关闭信息密度/classic 底色(语义叠加层不受影响) */
disableInfoDensityRender?: boolean;
/** 差分模式配置 */
diff?: DiffOverlayOptions;
/** 语义分析模式配置 */
semantic?: SemanticOverlayOptions;
/**
* 可选:覆盖底色映射上限(与全站默认 18 / 6 不同)。
* classic:token surprisal(bits);density:信息密度(bits/Byte)。例如 Chat 固定为 2。
*/
surprisalColorMax?: number;
}
export class SvgOverlayManager {
private rectCache: Map<string, RectCacheEntry> = new Map();
/** 语义分析叠加层 rect 缓存(与 base rect 同位置,用于 resize 时同步更新) */
private semanticOverlayCache: Map<string, SVGRectElement> = new Map();
private baseNode: HTMLElement;
private options: SvgOverlayManagerOptions;
constructor(baseNode: HTMLElement, options: SvgOverlayManagerOptions) {
this.baseNode = baseNode;
this.options = options;
}
/**
* 创建SVG覆盖层
* @param positions token位置数组
* @param rd 分析结果数据
* @returns SVG元素
*/
createSvgOverlay(positions: TokenFragmentRect[], rd: FrontendAnalyzeResult): SVGSVGElement {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'svg-overlay');
// 获取容器尺寸,设置SVG的viewBox和尺寸
// 使用clientWidth/clientHeight,与 TokenPositionCalculator 输出的坐标系一致
// (TokenPositionCalculator 已在源头处理了 zoom 转换)
const containerWidth = this.baseNode.clientWidth || this.baseNode.offsetWidth || 800;
const containerHeight = this.baseNode.clientHeight || this.calculateContainerHeight(positions);
// 设置SVG尺寸和viewBox(使用像素单位,确保rect位置正确)
svg.setAttribute('width', containerWidth.toString());
svg.setAttribute('height', containerHeight.toString());
svg.setAttribute('viewBox', `0 0 ${containerWidth} ${containerHeight}`);
svg.setAttribute('preserveAspectRatio', 'none');
// 设置SVG样式
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.pointerEvents = 'none';
svg.style.zIndex = '1';
// 清空rect缓存(重新创建时)
this.rectCache.clear();
this.semanticOverlayCache.clear();
// 按tokenIndex分组positions
const positionsByToken = this.groupPositionsByToken(positions);
// 为每个token创建一个group,包含该token的所有fragment rect
positionsByToken.forEach((tokenPositions, tokenIndex) => {
const group = this.createTokenGroup(svg, tokenPositions, tokenIndex, rd);
svg.appendChild(group);
});
return svg;
}
/**
* 更新SVG rect的位置和大小
* @param svg SVG元素
* @param positions token位置数组
*/
updateSvgPositions(svg: SVGSVGElement, positions: TokenFragmentRect[]): void {
// 更新SVG的viewBox和尺寸
const containerWidth = this.baseNode.clientWidth || 0;
const containerHeight = this.baseNode.clientHeight || this.calculateContainerHeight(positions);
svg.setAttribute('width', containerWidth.toString());
svg.setAttribute('height', containerHeight.toString());
svg.setAttribute('viewBox', `0 0 ${containerWidth} ${containerHeight}`);
// 更新每个rect的位置和大小
// 不需要缩放转换,因为 TokenPositionCalculator 已在源头处理了 zoom
// 宽度见 displayWidth(零宽 Range 在 SVG 中需要占位宽;与 DAG 测量的过滤策略不同)
positions.forEach(pos => {
const x = Math.max(0, pos.x);
const y = Math.max(0, pos.y);
const width = Math.max(1, this.displayWidth(pos));
const height = Math.max(1, pos.height);
const attrs = { x, y, width, height };
const cacheEntry = this.rectCache.get(pos.rectKey);
if (cacheEntry?.rect) {
Object.entries(attrs).forEach(([k, v]) => cacheEntry.rect.setAttribute(k, v.toString()));
}
const overlayRect = this.semanticOverlayCache.get(pos.rectKey);
if (overlayRect) {
Object.entries(attrs).forEach(([k, v]) => overlayRect.setAttribute(k, v.toString()));
}
});
}
/**
* 获取rect缓存
*/
getRectCache(): Map<string, RectCacheEntry> {
return this.rectCache;
}
/** 获取语义叠加层 rect 缓存(用于动画等) */
getSemanticOverlayCache(): Map<string, SVGRectElement> {
return this.semanticOverlayCache;
}
/**
* 追加新 token 的 rect 到现有 SVG,不重建已有结构。
* 用于 chunk 模式下 token 数量增长时的增量追加。
*/
appendTokenRects(newPositions: TokenFragmentRect[], svg: SVGSVGElement, rd: FrontendAnalyzeResult): void {
const positionsByToken = this.groupPositionsByToken(newPositions);
positionsByToken.forEach((tokenPositions, tokenIndex) => {
const group = this.createTokenGroup(svg, tokenPositions, tokenIndex, rd);
svg.appendChild(group);
});
}
/**
* 更新 base rect(信息密度/classic 底色)颜色,不重建 SVG。
* 用于 Disable info density / token render style 切换时,增量路径下刷新底色。
*/
updateBaseRectColors(
rd: FrontendAnalyzeResult,
overrides: { disableInfoDensityRender: boolean; tokenRenderStyle: 'density' | 'classic' }
): void {
this.rectCache.forEach(({ rect, tokenIndex }, rectKey) => {
const color = this.computeBaseRectColor(rectKey, tokenIndex, rd, overrides);
rect.setAttribute('fill', color);
rect.setAttribute('data-target-color', color);
});
}
/**
* 仅更新语义叠加层颜色,不重建 SVG 结构。
* 用于 chunk 模式下每次 score 更新时的增量渲染。
* 利用 rectKey 命名约定(tokenIndex-fragmentIndex),只遍历 fromTokenIndex 之后的 rect。
* @returns 实际更新的 rect 数量(用于验证日志)
*/
updateSemanticColors(rawScoresNormed: (number | undefined)[], fromTokenIndex = 0): number {
let count = 0;
for (let tokenIndex = fromTokenIndex; tokenIndex < rawScoresNormed.length; tokenIndex++) {
const score = rawScoresNormed[tokenIndex];
const color = score !== undefined ? getSemanticSimilarityColor(score) : 'transparent';
for (let i = 0; ; i++) {
const rectKey = `${tokenIndex}-${i}`;
const overlayRect = this.semanticOverlayCache.get(rectKey);
if (!overlayRect) break;
overlayRect.setAttribute('fill', color);
overlayRect.setAttribute('data-target-color', color);
count++;
}
}
return count;
}
/**
* 清空rect缓存
*/
clearRectCache(): void {
this.rectCache.clear();
this.semanticOverlayCache.clear();
}
/**
* 检查是否有缺失的rect(用于判断是否需要重新渲染)
*/
hasMissingRects(positions: TokenFragmentRect[]): boolean {
return positions.some(pos => !this.rectCache.has(pos.rectKey)) ||
positions.length !== this.rectCache.size;
}
/**
* 计算容器高度
*/
private calculateContainerHeight(positions: TokenFragmentRect[]): number {
const textLayer = this.baseNode.querySelector('.text-layer') as HTMLElement;
const containerRect = this.baseNode.getBoundingClientRect();
// 计算所有token的最大底部位置(重复逻辑提取)
const maxTokenBottom = positions.length > 0
? Math.max(...positions.map(p => p.y + p.height))
: 0;
if (textLayer) {
// 使用文本层的实际高度(包括padding)
const textLayerRect = textLayer.getBoundingClientRect();
return Math.max(
textLayerRect.height || 0,
maxTokenBottom,
this.baseNode.clientHeight || 0
);
} else {
// 如果没有文本层,使用所有token的最大y + height,或者容器的scrollHeight
return Math.max(
maxTokenBottom,
this.baseNode.scrollHeight || 0,
this.baseNode.clientHeight || 0,
containerRect.height || 0,
600
);
}
}
/**
* 按tokenIndex分组positions
*/
private groupPositionsByToken(positions: TokenFragmentRect[]): Map<number, TokenFragmentRect[]> {
const positionsByToken = new Map<number, TokenFragmentRect[]>();
positions.forEach(pos => {
if (!positionsByToken.has(pos.tokenIndex)) {
positionsByToken.set(pos.tokenIndex, []);
}
positionsByToken.get(pos.tokenIndex)!.push(pos);
});
return positionsByToken;
}
/**
* 创建token group
*/
private createTokenGroup(
svg: SVGSVGElement,
tokenPositions: TokenFragmentRect[],
tokenIndex: number,
rd: FrontendAnalyzeResult
): SVGGElement {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('data-token-index', tokenIndex.toString());
group.setAttribute('class', 'token-group');
group.style.pointerEvents = 'auto';
group.style.cursor = 'pointer';
tokenPositions.forEach(pos => {
const rect = this.createRect(pos, tokenIndex, rd);
group.appendChild(rect);
// 语义分析模式:在信息密度之上叠加语义高亮(黄色渐变)
const sem = this.options.semantic;
if (sem?.analysisMode) {
const overlayRect = this.createSemanticOverlayRect(pos, tokenIndex, rd);
group.appendChild(overlayRect);
}
});
this.options.addTokenEventListeners(group, tokenIndex, rd);
return group;
}
/**
* 计算 base rect 底色(与 createRect 共用逻辑,供 updateBaseRectColors 复用)
*/
private computeBaseRectColor(
_rectKey: string,
tokenIndex: number,
rd: FrontendAnalyzeResult,
overrides?: { disableInfoDensityRender: boolean; tokenRenderStyle: 'density' | 'classic' }
): string {
const disableInfoDensityRender = overrides?.disableInfoDensityRender ?? this.options.disableInfoDensityRender;
const tokenRenderStyle = overrides?.tokenRenderStyle ?? this.options.tokenRenderStyle ?? 'classic';
if (this.options.diff?.enabled && this.options.diff.deltaByteSurprisals.length > 0) {
const diff = this.options.diff;
const tokenData = rd.bpe_strings[tokenIndex];
const offset = tokenData.offset;
const charStart = offset[0];
const charEnd = offset[1];
const charToByteIndexMap = diff.charToByteIndexMap;
const deltaByteSurprisals = diff.deltaByteSurprisals;
const tokenByteDeltas: number[] = [];
if (!charToByteIndexMap.length) return getDiffColor(0);
const byteStart = charToByteIndexMap[charStart] ?? charStart;
const byteEnd = charToByteIndexMap[charEnd] ?? charEnd;
for (let byteIdx = byteStart; byteIdx < byteEnd && byteIdx < deltaByteSurprisals.length; byteIdx++) {
tokenByteDeltas.push(deltaByteSurprisals[byteIdx]);
}
const avgDelta = tokenByteDeltas.length > 0
? tokenByteDeltas.reduce((sum, val) => sum + val, 0) / tokenByteDeltas.length
: 0;
return getDiffColor(avgDelta);
}
if (disableInfoDensityRender) return 'transparent';
const tokenData = rd.bpe_strings[tokenIndex];
const cap = this.options.surprisalColorMax;
if (tokenRenderStyle === 'classic') {
const topk = this.options.getTokenRealTopk(rd, tokenIndex);
const surprisal = topk != null ? calculateSurprisal(topk[1]) : 0;
return getTokenSurprisalColor(surprisal, undefined, cap);
}
return getByteSurprisalColor(calculateSurprisalDensity(tokenData), 1, undefined, cap);
}
/**
* 创建rect元素
*/
private createRect(
pos: TokenFragmentRect,
tokenIndex: number,
rd: FrontendAnalyzeResult
): SVGRectElement {
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
this.setRectGeometry(rect, pos);
// 设置token索引和片段信息
rect.setAttribute('data-token-index', pos.tokenIndex.toString());
rect.setAttribute('data-fragment-index', pos.fragmentIndex.toString());
rect.setAttribute('data-rect-key', pos.rectKey);
// 缓存rect引用,避免后续querySelector查询
this.rectCache.set(pos.rectKey, { rect, tokenIndex: pos.tokenIndex });
const color = this.computeBaseRectColor(pos.rectKey, pos.tokenIndex, rd);
// 设置填充颜色
rect.setAttribute('fill', color);
rect.setAttribute('data-target-color', color); // 保存目标颜色,用于动画
// rect的pointer-events由group控制,这里不需要单独设置
rect.style.pointerEvents = 'auto';
return rect;
}
/**
* 创建语义分析叠加层 rect(黄色渐变,叠加在信息密度之上)
* 与 createRect 保持一致的 rect 属性和动画支持
*/
private createSemanticOverlayRect(
pos: TokenFragmentRect,
tokenIndex: number,
rd: FrontendAnalyzeResult
): SVGRectElement {
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
const sem = this.options.semantic!;
const score = sem.rawScoresNormed![tokenIndex];
const color = score !== undefined ? getSemanticSimilarityColor(score) : 'transparent';
this.setRectGeometry(rect, pos);
rect.setAttribute('data-token-index', pos.tokenIndex.toString());
rect.setAttribute('data-fragment-index', pos.fragmentIndex.toString());
rect.setAttribute('data-rect-key', pos.rectKey);
rect.setAttribute('fill', color);
rect.setAttribute('data-target-color', color);
rect.style.pointerEvents = 'auto';
this.semanticOverlayCache.set(pos.rectKey, rect);
return rect;
}
/** 设置 rect 的几何属性(与 createRect 共用,保持一致性) */
private setRectGeometry(rect: SVGRectElement, pos: TokenFragmentRect): void {
const x = Math.max(0, pos.x);
const y = Math.max(0, pos.y);
const width = Math.max(1, this.displayWidth(pos));
const height = Math.max(1, pos.height);
rect.setAttribute('x', x.toString());
rect.setAttribute('y', y.toString());
rect.setAttribute('width', width.toString());
rect.setAttribute('height', height.toString());
rect.setAttribute('rx', '6');
rect.setAttribute('ry', '6');
}
/**
* GLTR SVG 底色需要可读的最小矩形宽。Range 对某些片段(典型为换行、以及 WebKit/iOS 的换行幽灵片)
* 会给出 width=0;SvgOverlay 必须把零宽扩成占位宽才可画。
* DAG 测量的纠正在 `genAttributeDagTextMeasure.ts`:非纯换行 token 会跳过 width=0 的幽灵片以对齐 DAG。
*/
private displayWidth(pos: TokenFragmentRect): number {
return pos.width > 0 ? pos.width : ZERO_WIDTH_FRAGMENT_PLACEHOLDER_PX;
}
}