InfoLens / client /src /shared /cross /topkChartUtils.ts
dqy08's picture
重构仓库目录;增加Propagated attribution动画;UI改进
17037b0
Raw
History Blame Contribute Delete
7.46 kB
/**
* 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 `<div class="row topk-chart-row topk-chart-row--ellipsis">⋮</div>`;
}
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 = `<div class="topk-chart-bar-cell" style="width:${barCellW}px;">` +
`<span class="topk-chart-bar-fill" style="width: ${scale(d.prob)}px;"></span>` +
` <span class="topk-chart-prob">${numF(d.prob)}</span></div>`;
const text = `<div class="topk-chart-token-cell">${processCandidateText(d.token)}</div>`;
const pickAttr = pickable ? ` data-topk-pick="${encodeURIComponent(d.token)}"` : '';
return `<div class="${rowClasses}"${pickAttr}>${bar}${text}</div>`;
});
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 ? `<div class="semantic-debug-topk-chart predictions predictions-table">${rows}</div>` : '';
}