| import { getByteSurprisalColor, getSemanticSimilarityColor, MINIMAP_COLOR_FACTOR, SEMANTIC_MINIMAP_COLOR_FACTOR } from '../cross/SurprisalColorConfig'; |
| import { isNarrowScreen } from '../core/responsive'; |
| import { calculateSurprisalDensity, isFiniteNumber } from '../core/Util'; |
| import type { TokenFragmentRect } from './types'; |
| import type { FrontendAnalyzeResult } from '../../shared/api/GLTR_API'; |
|
|
| |
| |
| |
| export interface MinimapOptions { |
| width?: number; |
| } |
|
|
| |
| |
| |
| interface AggregationResult { |
| buckets: BucketData[]; |
| } |
|
|
| |
| |
| |
| interface BucketData { |
| y: number; |
| surprisalDensitySum: number; |
| TokenFragmentCount: number; |
| } |
|
|
| type MinimapRenderData = FrontendAnalyzeResult & { |
| chunkInfos?: Array<{ |
| startOffset: number; |
| endOffset: number; |
| chunkMatchDegree: number; |
| }>; |
| }; |
|
|
| interface MinimapRenderOptions { |
| semanticAnalysisMode?: boolean; |
| measureCharRangeY?: (startOffset: number, endOffset: number) => { minY: number; maxY: number } | null; |
| } |
|
|
| |
| |
| |
| |
| export class ScrollbarMinimap { |
| private canvas: HTMLCanvasElement; |
| private portal: HTMLElement; |
| private container: HTMLElement; |
| private options: MinimapOptions; |
|
|
| constructor(container: HTMLElement, options: MinimapOptions) { |
| this.container = container; |
| this.options = { |
| width: 12, |
| ...options |
| }; |
|
|
| |
| this.createPortal(); |
| } |
|
|
| |
| |
| |
| private createPortal(): void { |
| |
| this.portal = document.createElement('div'); |
| this.portal.className = 'surprisal-minimap-portal'; |
| this.portal.style.cssText = ` |
| position: fixed; |
| right: 0; |
| top: 0; |
| pointer-events: none; |
| z-index: 1; |
| `; |
|
|
| |
| this.canvas = document.createElement('canvas'); |
| this.canvas.className = 'surprisal-minimap'; |
| this.canvas.style.cssText = ` |
| width: ${this.options.width}px; |
| `; |
|
|
| this.portal.appendChild(this.canvas); |
|
|
| |
| document.body.appendChild(this.portal); |
| } |
|
|
| |
| |
| |
| public hide(): void { |
| this.portal.style.opacity = '0'; |
| } |
|
|
| |
| |
| |
| |
| private updateMinimapLayout(): void { |
| |
| this.canvas.width = this.options.width; |
| |
| this.canvas.style.width = `${this.options.width}px`; |
|
|
| let viewportHeight: number; |
|
|
| if (isNarrowScreen()) { |
| |
| const clientHeight = document.documentElement.clientHeight; |
| if (clientHeight) { |
| viewportHeight = clientHeight; |
| } else { |
| viewportHeight = window.innerHeight; |
| console.warn('[ScrollbarMinimap] 使用后备值 window.innerHeight,document.documentElement.clientHeight 不可用', { |
| clientHeight, |
| innerHeight: window.innerHeight, |
| viewportHeight |
| }); |
| } |
| } else { |
| |
| viewportHeight = window.innerHeight; |
| } |
|
|
| |
| this.canvas.height = viewportHeight; |
|
|
| |
| |
| this.portal.style.top = '0px'; |
| } |
|
|
| |
| |
| |
| |
| |
| public async render( |
| positions: TokenFragmentRect[], |
| renderData: FrontendAnalyzeResult, |
| renderOptions: MinimapRenderOptions = {} |
| ): Promise<void> { |
| if (positions.length === 0) { |
| this.clear(); |
| return; |
| } |
|
|
| |
| this.portal.style.opacity = '1'; |
|
|
| this.updateMinimapLayout(); |
|
|
| const ctx = this.canvas.getContext('2d'); |
| if (!ctx) return; |
|
|
| |
| ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); |
|
|
| |
| let worldUnitsPerMinimapPixel: number; |
| let textAreaTop = 0; |
|
|
| if (isNarrowScreen()) { |
| |
| const canvasHeight = this.canvas.height; |
| const scrollHeight = document.body.scrollHeight; |
| worldUnitsPerMinimapPixel = scrollHeight / canvasHeight; |
|
|
| |
| const containerRect = this.container.getBoundingClientRect(); |
| textAreaTop = window.scrollY + containerRect.top; |
| } else { |
| |
| const canvasHeight = this.canvas.height; |
| const textContentHeight = (this.container.querySelector('.text-layer') as HTMLElement).scrollHeight; |
| worldUnitsPerMinimapPixel = textContentHeight / canvasHeight; |
| |
| |
| const minWorldUnitsPerMinimapPixel = 1.0; |
| worldUnitsPerMinimapPixel = Math.max(minWorldUnitsPerMinimapPixel, worldUnitsPerMinimapPixel); |
| } |
|
|
| |
| let y_min = 0; |
| let y_max: number; |
| |
| if (isNarrowScreen()) { |
| |
| y_max = document.body.scrollHeight; |
| } else { |
| |
| const textLayer = this.container.querySelector('.text-layer') as HTMLElement; |
| y_max = textLayer ? textLayer.scrollHeight : 0; |
| } |
|
|
| const extendedRenderData = renderData as MinimapRenderData; |
| const chunkInfos = extendedRenderData.chunkInfos ?? []; |
|
|
| |
| if (renderOptions.semanticAnalysisMode) { |
| if (chunkInfos.length > 0) { |
| this.renderChunkMatchMinimap( |
| ctx, |
| chunkInfos, |
| textAreaTop, |
| worldUnitsPerMinimapPixel, |
| renderOptions.measureCharRangeY |
| ); |
| } |
| return; |
| } |
|
|
| |
| |
| const lineHeight = positions[0].height; |
| |
| let bucketCount: number; |
| |
| if (lineHeight > 0) { |
| bucketCount = Math.floor((y_max - y_min) / lineHeight); |
| } else { |
| console.log('[ScrollbarMinimap] 行高度为0,跳过bucketCount计算', { |
| positions, |
| lineHeight |
| }); |
| bucketCount = Infinity; |
| } |
|
|
| |
| const minBucketHeightOnMinimap = 2; |
| const maxBucketCountByMinimap = Math.floor(this.canvas.height / minBucketHeightOnMinimap); |
| bucketCount = Math.min(bucketCount, maxBucketCountByMinimap); |
| |
| const bucketHeight = (y_max - y_min) / bucketCount; |
| const actualBucketHeightOnMinimap = Math.max(1, bucketHeight / worldUnitsPerMinimapPixel); |
|
|
| |
| const aggregationResult = this.aggregateToBuckets(positions, renderData, bucketCount, y_min, y_max); |
| const { buckets } = aggregationResult; |
|
|
| buckets.forEach(bucket => { |
| |
| const averageSurprisalDensity = bucket.TokenFragmentCount > 0 |
| ? bucket.surprisalDensitySum / bucket.TokenFragmentCount |
| : 0; |
| const color = getByteSurprisalColor(averageSurprisalDensity, MINIMAP_COLOR_FACTOR); |
| ctx.fillStyle = color; |
|
|
| const y = (textAreaTop + bucket.y) / worldUnitsPerMinimapPixel; |
| ctx.fillRect(0, y, this.canvas.width, actualBucketHeightOnMinimap); |
| }); |
| } |
|
|
| private renderChunkMatchMinimap( |
| ctx: CanvasRenderingContext2D, |
| chunkInfos: Array<{ startOffset: number; endOffset: number; chunkMatchDegree: number }>, |
| textAreaTop: number, |
| worldUnitsPerMinimapPixel: number, |
| measureCharRangeY?: (startOffset: number, endOffset: number) => { minY: number; maxY: number } | null |
| ): void { |
| |
| |
| let prevEndPx: number | null = null; |
| let prevChunkIndex: number | null = null; |
|
|
| for (let idx = 0; idx < chunkInfos.length; idx++) { |
| const chunk = chunkInfos[idx]!; |
| if (!isFiniteNumber(chunk.chunkMatchDegree)) continue; |
|
|
| const chunkIndex = ('chunkIndex' in chunk ? (chunk as any).chunkIndex : undefined) ?? idx; |
|
|
| const measured = measureCharRangeY?.(chunk.startOffset, chunk.endOffset); |
| if (!measured) { |
| continue; |
| } |
|
|
| const { minY, maxY } = measured; |
|
|
| if (!Number.isFinite(minY) || !Number.isFinite(maxY) || maxY <= minY) continue; |
|
|
| const rawMinYPx = (textAreaTop + minY) / worldUnitsPerMinimapPixel; |
| const rawMaxYPx = (textAreaTop + maxY) / worldUnitsPerMinimapPixel; |
|
|
| |
| const y = prevEndPx == null ? rawMinYPx : prevEndPx; |
| const hRaw = rawMaxYPx - y; |
| const h = hRaw < 0 ? 0 : hRaw; |
| const drawnEndPx = y + h; |
|
|
| |
| ctx.fillStyle = getSemanticSimilarityColor( |
| chunk.chunkMatchDegree * SEMANTIC_MINIMAP_COLOR_FACTOR |
| ); |
| ctx.fillRect(0, y, this.canvas.width, h); |
|
|
| prevEndPx = drawnEndPx; |
| prevChunkIndex = chunkIndex; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| private aggregateToBuckets( |
| positions: TokenFragmentRect[], |
| renderData: FrontendAnalyzeResult, |
| bucketCount: number, |
| y_min: number, |
| y_max: number |
| ): AggregationResult { |
| |
| if (positions.length === 0 || y_max <= y_min) { |
| return { |
| buckets: [] |
| }; |
| } |
|
|
| |
| const bucketHeight = (y_max - y_min) / bucketCount; |
|
|
| |
| const buckets: BucketData[] = Array.from({ length: bucketCount }, (_, bucketIndex) => ({ |
| y: y_min + bucketIndex * bucketHeight, |
| surprisalDensitySum: 0, |
| TokenFragmentCount: 0 |
| })); |
|
|
| |
| positions.forEach(pos => { |
| |
| const centerY = pos.y + pos.height / 2; |
| const bucketIndex = Math.floor((centerY - y_min) / bucketHeight); |
| |
| |
| if (bucketIndex < 0 || bucketIndex >= bucketCount) { |
| return; |
| } |
|
|
| const bucket = buckets[bucketIndex]; |
|
|
| |
| const token = renderData.bpe_strings[pos.tokenIndex]; |
| const surprisalDensity = calculateSurprisalDensity(token); |
| bucket.surprisalDensitySum += surprisalDensity; |
| bucket.TokenFragmentCount += 1; |
| |
| }); |
|
|
| return { |
| buckets |
| }; |
| } |
|
|
|
|
| |
| |
| |
| public updateOptions(options: Partial<MinimapOptions>): void { |
| this.options = { ...this.options, ...options }; |
| } |
|
|
| |
| |
| |
| public clear(): void { |
| const ctx = this.canvas.getContext('2d'); |
| if (ctx) { |
| ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); |
| } |
| this.portal.style.opacity = '0'; |
| } |
|
|
| |
| |
| |
| public destroy(): void { |
| this.clear(); |
| |
| if (this.portal.parentElement) { |
| this.portal.parentElement.removeChild(this.portal); |
| } |
| } |
| } |
|
|