/** * Top-K 条形图 HTML 渲染(行结构、省略行、高亮列),供 Tooltip、语义 debug、归因复用。 * * 主题与颜色: * ——不采用「内联写死主题色 + 切换 data-theme 时用 JS 整段重绘 innerHTML」的做法。 * 行/条/说明文字的颜色由 `.predictions-table` 下的类名配合 `start.scss` 中 `:root` / * `:root[data-theme="dark"]` 的 `--tooltip-text-normal`、`--tooltip-text-selected`、 * `--tooltip-text-detail` 随主题变化;仅条形宽度等纯数据量保留内联 style。 * * 从 {@link FrontendToken} 拼出上述 HTML 见 {@link ./tooltipPredictionsFromToken}。 */ import * as d3 from 'd3'; import { processCandidateText } from './tokenDisplayUtils'; /** * 与 analysis.html 主视图 Tooltip 中 Top-K 条形图概率列一致({@link renderTopkChartHtml} 默认格式)。 * @param v 模型给出的概率,区间 [0, 1] */ export function formatTopkTooltipProbabilityPercent(v: number): string { return d3.format('.3g')(v * 100) + '%'; } /** Tooltip 默认条形宽度 */ const MAX_BAR_WIDTH = 60; /** Semantic debug 专用:更大条形与列宽,tooltip 不受影响 */ const SEMANTIC_DEBUG_MAX_BAR = 100; const SEMANTIC_DEBUG_BAR_CELL = 180; /** 插入数据中表示省略行的占位符,左侧列空、token 列显示 ⋮ */ export const TOPK_SEP = '\0__TOPK_SEP__\0'; /** 与后端 round_to_sig_figs 及 JSON 往返对齐,用于 top-k 行与 attribution target 概率配对 */ function probabilitiesEffectivelyEqual(a: number, b: number): boolean { if (a === b) return true; const d = Math.abs(a - b); return d < 1e-12 || d / Math.max(Math.abs(a), Math.abs(b), 1e-15) < 1e-9; } export interface TopkChartOptions { /** 高亮的 token(与当前 token 一致时用 `.topk-chart-row--selected`) */ selectedToken?: string; /** * 与 selectedToken 同时传入时,只高亮「第一个」token 与 prob 均匹配的行(避免多 id 解码同形时多行同时选中)。 * 不传时退化为仅按 token 匹配第一个。 */ selectedProb?: number; /** 条形最大宽度 px */ maxBarWidth?: number; /** 条形列单元格宽度 px */ barCellWidth?: number; numFormat?: (n: number) => string; /** 为候选行加 data-topk-pick 与指针样式(归因弹窗点选目标用) */ interactivePickable?: boolean; } /** 若 actualToken 不在行中则追加 ⋮ + 该行;供 {@link prepareTopkDisplayRows} 专用 */ function mergeTopkRowsWithActualTokenIfAbsent( rows: Array<{ token: string; prob: number }>, actualToken: string, actualProb: number ): Array<{ token: string; prob: number }> { if (!rows.length || actualToken === '') return rows; if (!Number.isFinite(actualProb)) return rows; if (rows.some((d) => d.token === actualToken)) return rows; return [...rows, { token: TOPK_SEP, prob: 0 }, { token: actualToken, prob: actualProb }]; } export type TopkDisplaySelection = { token: string; prob: number }; /** 从任意来源的 token + 概率构造可选的展示选中项;不合法时返回 `undefined`。 */ export function topkDisplaySelection( token: string | undefined | null, prob: number | null | undefined ): TopkDisplaySelection | undefined { if (token == null || token === '') return undefined; if (prob == null || !Number.isFinite(prob)) return undefined; return { token, prob }; } /** * 统一入口:在服务端/模型给出的 base top-k 行上,按需合并「选中 token」行。 * - `selection` 为 `undefined`:不追加(仅展示 base,如无真实信息密度则 Tooltip 不传 selection)。 * - `selection` 有值且 token 已出现在 base 中:不重复追加。 * - 条形图红色高亮由调用方对 {@link renderTopkChartHtml} 传入 `selectedToken`(及可选 `selectedProb`)。 */ export function prepareTopkDisplayRows( baseRows: Array<{ token: string; prob: number }>, selection: TopkDisplaySelection | undefined ): Array<{ token: string; prob: number }> { if (!selection || !baseRows.length) return baseRows; const { token, prob } = selection; if (token === '' || !Number.isFinite(prob)) return baseRows; return mergeTopkRowsWithActualTokenIfAbsent(baseRows, token, prob); } /** 计算应高亮的行下标:优先 token+prob 唯一命中第一个,否则第一个 token 命中 */ function resolveSelectedRowIndex( data: Array<{ token: string; prob: number }>, selectedToken: string | undefined, selectedProb: number | undefined ): number { if (selectedToken === undefined || selectedToken === '') return -1; if (selectedProb != null && Number.isFinite(selectedProb)) { const i = data.findIndex( (d) => d.token !== TOPK_SEP && d.token === selectedToken && probabilitiesEffectivelyEqual(d.prob, selectedProb) ); if (i >= 0) return i; } return data.findIndex((d) => d.token !== TOPK_SEP && d.token === selectedToken); } /** 生成与 Tooltip 完全一致的 TopK 图表 HTML */ export function renderTopkChartHtml( data: Array<{ token: string; prob: number }>, options?: TopkChartOptions ): string { if (!data.length) return ''; const maxBar = options?.maxBarWidth ?? MAX_BAR_WIDTH; const numF = options?.numFormat ?? formatTopkTooltipProbabilityPercent; /** 条形满宽对应概率 100%(1),与显示的百分比刻度一致 */ const scale = d3.scaleLinear().domain([0, 1]).range([0, maxBar]); const barCellW = options?.barCellWidth ?? 110; const pickable = options?.interactivePickable === true; const highlightRowIndex = resolveSelectedRowIndex(data, options?.selectedToken, options?.selectedProb); const rows = data.map((d, i) => { if (d.token === TOPK_SEP) { return `
`; } const isSelected = highlightRowIndex >= 0 && i === highlightRowIndex; const rowClasses = [ 'row', 'topk-chart-row', pickable ? 'topk-chart-row--pickable' : '', isSelected ? 'topk-chart-row--selected' : '', ] .filter(Boolean) .join(' '); const bar = `
` + `` + ` ${numF(d.prob)}
`; const text = `
${processCandidateText(d.token)}
`; const pickAttr = pickable ? ` data-topk-pick="${encodeURIComponent(d.token)}"` : ''; return `
${bar}${text}
`; }); return rows.join(''); } /** 生成完整 TopK 图表 HTML(含容器),用于独立展示如 semantic debug。 */ export function renderTopkChartFullHtml(data: Array<{ token: string; prob: number }>, options?: TopkChartOptions): string { const opts = options ?? {}; const semanticOpts = { ...opts, maxBarWidth: opts.maxBarWidth ?? SEMANTIC_DEBUG_MAX_BAR, barCellWidth: opts.barCellWidth ?? SEMANTIC_DEBUG_BAR_CELL, }; const rows = renderTopkChartHtml(data, semanticOpts); return rows ? `
${rows}
` : ''; }