File size: 17,423 Bytes
494c9e4
 
 
 
 
17037b0
 
 
494c9e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
/**
 * SVG覆盖层管理器
 * 负责创建和管理SVG覆盖层,包括rect元素的创建、位置更新等
 */

import {FrontendAnalyzeResult} from "../../shared/api/GLTR_API";
import {calculateSurprisal, calculateSurprisalDensity} from "../core/Util";
import {getByteSurprisalColor, getTokenSurprisalColor, getDiffColor, getSemanticSimilarityColor} from "../cross/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;
    }
}