| 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 = '─────────────'; |
|
|
| |
| const CORNER_INSET_PX = 0; |
|
|
| export type ToolTipOptions = { |
| |
| surprisalRowLabel?: string; |
| |
| |
| |
| |
| |
| placement?: 'anchor' | 'parent-bottom-right'; |
| |
| |
| |
| |
| pointerInteractive?: boolean; |
| }; |
|
|
| |
| export type ToolTipUpdateAugment = { |
| |
| 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; |
| |
| |
| private readonly numF = d3.format('.3f'); |
| private readonly significantF = d3.format('.3g'); |
| |
| |
| private themeColors = { |
| selectedColor: '#933', |
| detailColor: '#666666', |
| valueColor: '#333' |
| }; |
| |
| |
| 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 { |
| |
| 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'; |
| |
| 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' |
| }; |
| } |
| |
| |
| |
| |
| |
| |
| private _getViewportInfo(): { |
| width: number; |
| height: number; |
| offsetTop: number; |
| offsetLeft: number; |
| } { |
| |
| if (window.visualViewport) { |
| return { |
| width: window.visualViewport.width, |
| height: window.visualViewport.height, |
| offsetTop: window.visualViewport.offsetTop || 0, |
| offsetLeft: window.visualViewport.offsetLeft || 0 |
| }; |
| } |
| |
| |
| return { |
| width: window.innerWidth, |
| height: document.documentElement.clientHeight || window.innerHeight, |
| offsetTop: 0, |
| offsetLeft: 0 |
| }; |
| } |
| |
| |
| |
| |
| 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'; |
| } |
| } |
| } |
|
|
| |
| 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'); |
| } |
| } |
|
|
|
|
| |
| |
| |
| 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 }; |
|
|
| |
| |
| |
| 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; |
|
|
| |
| 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); |
|
|
| |
| this._updatePosition(at); |
| }); |
| } |
| |
| |
| |
| |
| |
| private _updateContent(ri: GLTR_RenderItem, augment?: ToolTipUpdateAugment): void { |
| const { selectedColor, detailColor, valueColor } = this.themeColors; |
|
|
| |
| 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); |
|
|
| |
| |
| 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)); |
| } |
|
|
| |
| 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)); |
| } |
|
|
| |
| 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/>')); |
|
|
| |
| const predInner = buildTooltipPredictionsInnerHtml(tokenData); |
| if (predInner === '') { |
| this.predictions.selectAll('.row').data([]).join('div').remove(); |
| } else { |
| this.predictions.html(predInner); |
| } |
| } |
|
|
| |
| |
| |
| 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(); |
|
|
| |
| |
| 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; |
| } |
|
|
| |
| const tooltipRect = tooltipNode.getBoundingClientRect(); |
| const tooltipWidth = tooltipRect.width || 250; |
| const tooltipHeight = tooltipRect.height || 100; |
| |
| const offset = 15; |
| |
| |
| 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', |
| }); |
| } |
|
|
|
|
| } |