| import * as d3 from 'd3'; |
| import "./utils/d3-polyfill"; |
|
|
| import '../css/start.scss' |
| import '../css/compare.scss' |
| import {SimpleEventHandler} from "./utils/SimpleEventHandler"; |
| import type {AnalysisData, AnalyzeResponse, FrontendAnalyzeResult, FrontendToken} from "./api/GLTR_API"; |
| import {TextAnalysisAPI} from "./api/GLTR_API"; |
| import URLHandler from "./utils/URLHandler"; |
| import {Histogram, type HistogramBinClickEvent} from './vis/Histogram'; |
| import {ScatterPlot} from './vis/ScatterPlot'; |
| import {getDiffColor} from './utils/SurprisalColorConfig'; |
| import {initThemeManager} from './ui/theme'; |
| import {showAlertDialog, showDialog, showConfirmDialog} from './ui/dialog'; |
| import {initDemoManager, type DemoManager} from './ui/demoManager'; |
| import {isValidDemoFormat} from './utils/localFileUtils'; |
| import {normalizeDemoPath, getDemoName} from './utils/pathUtils'; |
| |
| import { DemoResourceLoader } from './storage/demoResourceLoader'; |
| import { LocalFileIO } from './storage/localFileIO'; |
| import { extractErrorMessage } from './utils/errorUtils'; |
| import { |
| cloneFrontendToken, |
| mergeTokensForRendering, |
| createRawSnapshot |
| } from './utils/tokenUtils'; |
| import { |
| validateTokenConsistency, |
| validateTokenProbabilities, |
| validateTokenPredictions |
| } from './utils/dataValidation'; |
| import { |
| calculateTextStats, |
| calculateDiffStats, |
| calculateMergedTokenSurprisals, |
| computeAverage, |
| computeP90, |
| type TextStats, |
| type DiffStats |
| } from './utils/textStatistics'; |
| import { updateBasicMetrics, updateTotalSurprisal, updateModel, validateMetricsElements, type DiffModeConfig } from './utils/textMetricsUpdater'; |
| import {GLTR_Text_Box, GLTR_Mode, GLTR_HoverEvent} from './vis/GLTR_Text_Box'; |
| import {ToolTip} from './vis/ToolTip'; |
| import { calculateHighlights } from './utils/highlightUtils'; |
| |
| import {initializeCommonApp} from './appInitializer'; |
| import { |
| getTokenSurprisalHistogramConfig, |
| getByteSurprisalHistogramConfig, |
| getDeltaByteSurprisalHistogramConfig, |
| getSurprisalProgressConfig |
| } from "./utils/visualizationConfigs"; |
| import { tr, initI18n } from './lang/i18n-lite'; |
| import { addDigitsMergeRenderListener, getDigitsMergeEnabled } from './utils/digitsMergeManager'; |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const toSafeId = (id: string): string => { |
| |
| if (!id || typeof id !== 'string' || id.length === 0) { |
| return 'empty'; |
| } |
| |
| |
| const trimmedId = id.trim(); |
| if (trimmedId.length === 0) { |
| return 'empty'; |
| } |
| |
| |
| |
| let hash = 5381; |
| for (let i = 0; i < trimmedId.length; i++) { |
| const charCode = trimmedId.charCodeAt(i); |
| |
| hash = ((hash << 5) + hash) + charCode; |
| } |
| |
| |
| |
| const positiveHash = Math.abs(hash); |
| const safeId = positiveHash.toString(36); |
| |
| |
| return safeId || 'empty'; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| type DemoColumnData = { |
| id: string; |
| demoPath: string; |
| demoName: string; |
| data: AnalysisData | null; |
| enhancedResult?: FrontendAnalyzeResult | null; |
| stats: TextStats | null; |
| diffStats?: DiffStats | null; |
| error: string | null; |
| originalText?: string; |
| lmfInstance?: GLTR_Text_Box; |
| histograms: { |
| stats_frac: Histogram | null; |
| stats_byte_frac: Histogram | null; |
| stats_surprisal_progress: ScatterPlot | null; |
| }; |
| }; |
|
|
| window.onload = () => { |
| |
| const api_prefix = URLHandler.parameters['api'] || ''; |
| const bodyElement = <Element>d3.select('body').node(); |
| const { eventHandler, api, tokenSurprisalColorScale, byteSurprisalColorScale, totalSurprisalFormat } = initializeCommonApp(api_prefix, bodyElement); |
|
|
| const container = d3.select('#compare-container'); |
| const mainFrame = d3.select('.main_frame'); |
|
|
| |
| const demoResourceLoader = new DemoResourceLoader(api); |
| const localFileIO = new LocalFileIO(); |
| const localDemoCache = demoResourceLoader.getLocalDemoCache(); |
|
|
| |
| const toolTip = new ToolTip(d3.select('#global_tooltip'), eventHandler); |
|
|
| |
| const demosParam = URLHandler.parameters['demos']; |
| let demoPaths: string[] = []; |
|
|
| if (demosParam) { |
| const raw = String(demosParam).trim(); |
| demoPaths = raw.split(',').map(p => p.trim()).filter(p => p.length > 0); |
| } |
|
|
| |
| const showTextRenderParam = URLHandler.parameters['showTextRender']; |
| const modelDiffModeParam = URLHandler.parameters['modelDiffMode']; |
|
|
| |
| const columnsData = new Map<string, DemoColumnData>(); |
| |
| |
| let modelDiffMode = modelDiffModeParam == '1'; |
| |
| |
| let showTextRender = showTextRenderParam == '1'; |
|
|
| |
| |
| |
| |
| const getBaseColumnId = (): string | null => { |
| const firstColumn = container.select('.compare-column').node() as HTMLElement | null; |
| if (!firstColumn) { |
| return null; |
| } |
| return firstColumn.getAttribute('data-column-id'); |
| }; |
|
|
| |
| |
| |
| const isBaseColumn = (columnId: string): boolean => { |
| const baseId = getBaseColumnId(); |
| return baseId === columnId; |
| }; |
|
|
| |
| |
| |
| |
| const recalculateAllDiffStats = (): void => { |
| if (!modelDiffMode) { |
| return; |
| } |
|
|
| const baseId = getBaseColumnId(); |
| if (!baseId) { |
| return; |
| } |
|
|
| const baseData = columnsData.get(baseId); |
| if (!baseData || !baseData.stats) { |
| return; |
| } |
|
|
| const baseStats = baseData.stats; |
|
|
| |
| baseData.diffStats = null; |
|
|
| |
| columnsData.forEach((columnData, columnId) => { |
| if (columnId === baseId) { |
| return; |
| } |
|
|
| if (columnData.stats) { |
| columnData.diffStats = calculateDiffStats(columnData.stats, baseStats); |
| } |
| }); |
| }; |
|
|
| const refreshAllColumnsAfterDigitsMerge = (): void => { |
| columnsData.forEach((columnData, id) => { |
| if (!columnData.data) return; |
| try { |
| const enhancedResult = processDemoData(columnData.data); |
| const safeText = columnData.data.request.text; |
| columnData.enhancedResult = enhancedResult; |
| columnData.stats = calculateTextStats(enhancedResult, safeText); |
| } catch (e) { |
| console.error('[compare] digit merge refresh failed for column', id, e); |
| } |
| }); |
| recalculateAllDiffStats(); |
| columnsData.forEach((columnData, id) => { |
| if (!columnData.stats || !columnData.data) return; |
| const resultModel = columnData.data.result.model; |
| updateMetricsForColumn(id, columnData.stats, resultModel); |
| renderStatsForColumn(id, columnData); |
| if (columnData.lmfInstance && columnData.enhancedResult) { |
| const isDiffColumn = modelDiffMode && columnData.diffStats && !isBaseColumn(id); |
| if (isDiffColumn && columnData.diffStats) { |
| columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals); |
| } else { |
| columnData.lmfInstance.setDiffMode(false, []); |
| } |
| columnData.lmfInstance.update(columnData.enhancedResult); |
| } |
| }); |
| }; |
|
|
| addDigitsMergeRenderListener(refreshAllColumnsAfterDigitsMerge); |
|
|
| |
|
|
| |
| const createColumnHTML = (id: string, demoName: string): string => { |
| |
| |
| const safeId = toSafeId(id); |
| const columnId = `compare-column-${safeId}`; |
| const statsId = `stats_demo_${safeId}`; |
| const headerId = `compare-header-${safeId}`; |
| const metricsId = `text_metrics_${safeId}`; |
| const errorId = `error_${safeId}`; |
| const statsFracId = `stats_frac_${safeId}`; |
| const statsByteFracId = `stats_byte_frac_${safeId}`; |
| const statsProgressId = `stats_surprisal_progress_${safeId}`; |
| const textRenderId = `text_render_${safeId}`; |
|
|
| return ` |
| <!-- data-column-id 使用原始 id(规范化路径),便于调试和查询,HTML 属性支持特殊字符 --> |
| <div id="${columnId}" class="compare-column" data-column-id="${id}"> |
| <div id="${headerId}" class="compare-header"> |
| <div class="column-actions-row"> |
| <button class="move-to-first-btn" title="${tr('Move to leftmost')}">⏮</button> |
| <button class="move-left-btn" title="${tr('Move left')}">◀</button> |
| <button class="delete-btn" title="${tr('Delete')}">×</button> |
| <button class="move-right-btn" title="${tr('Move right')}">▶</button> |
| <button class="move-to-last-btn" title="${tr('Move to rightmost')}">⏭</button> |
| </div> |
| <div class="column-title">${demoName}</div> |
| </div> |
| <div id="${errorId}" class="compare-error" style="display: none; color: var(--error-color, #f44336); padding: 10px; margin-bottom: 10px; background-color: var(--error-bg, rgba(244, 67, 54, 0.1)); border-radius: 4px;"></div> |
| <div id="${metricsId}" class="text-metrics is-hidden"> |
| <div class="text-metrics-primary"> |
| <span id="metric_bytes_${safeId}">0 B</span> |
| <span class="text-metrics-divider">|</span> |
| <span id="metric_chars_${safeId}">${tr('0 chars')}</span> |
| <span class="text-metrics-divider">|</span> |
| <span id="metric_tokens_${safeId}">0 tokens</span> |
| </div> |
| <div id="metric_total_surprisal_${safeId}" class="text-metrics-secondary">${tr('total information = 0 bits')}</div> |
| <div id="metric_model_${safeId}" class="text-metrics-secondary is-hidden">model: </div> |
| </div> |
| <div id="${statsId}" class="stats" style="text-align:center;"> |
| <div style="display:block;text-align: center;margin-bottom: 20px;"> |
| <div id="token_histogram_title_${safeId}"></div> |
| <svg id="${statsFracId}"></svg> |
| </div> |
| <div style="display:block;text-align: center;margin-bottom: 20px;"> |
| <div id="byte_histogram_title_${safeId}"></div> |
| <svg id="${statsByteFracId}"></svg> |
| </div> |
| <div style="display:block;text-align: center;margin-bottom: 20px;"> |
| <div id="surprisal_progress_title_${safeId}"></div> |
| <svg id="${statsProgressId}"></svg> |
| </div> |
| </div> |
| <div id="${textRenderId}" class="compare-text-render is-hidden"></div> |
| </div> |
| `; |
| }; |
|
|
| |
| const processDemoData = (data: AnalysisData): FrontendAnalyzeResult => { |
| const result = data.result; |
| const safeText = data.request.text; |
|
|
| |
| if (!Array.isArray(result.bpe_strings) || result.bpe_strings.length === 0) { |
| throw new Error(tr('Returned JSON missing valid bpe_strings array')); |
| } |
|
|
| const predTopkError = validateTokenPredictions(result.bpe_strings as Array<{ pred_topk?: [string, number][] }>); |
| if (predTopkError) { |
| throw new Error(predTopkError); |
| } |
|
|
| const probabilityError = validateTokenProbabilities(result.bpe_strings as Array<{ real_topk?: [number, number] }>); |
| if (probabilityError) { |
| throw new Error(probabilityError); |
| } |
|
|
| const validationError = validateTokenConsistency(result.bpe_strings, safeText, { allowOverlap: true }); |
| if (validationError) { |
| throw new Error(validationError); |
| } |
|
|
| |
| const originalTokens = result.bpe_strings.map((token) => cloneFrontendToken(token as FrontendToken)); |
| const bpeBpeMergedTokens = mergeTokensForRendering(originalTokens, safeText, { |
| digitMerge: getDigitsMergeEnabled(), |
| }); |
|
|
| const mergedValidationError = validateTokenConsistency(bpeBpeMergedTokens, safeText); |
| if (mergedValidationError) { |
| throw new Error(mergedValidationError); |
| } |
|
|
| const enhancedResult: FrontendAnalyzeResult = { |
| ...result, |
| originalTokens, |
| bpeBpeMergedTokens, |
| bpe_strings: bpeBpeMergedTokens, |
| originalText: safeText |
| }; |
|
|
| return enhancedResult; |
| }; |
|
|
| |
| const renderStatsForColumn = (id: string, columnData: DemoColumnData) => { |
| if (!columnData.stats || !columnData.histograms.stats_frac || !columnData.histograms.stats_byte_frac || !columnData.histograms.stats_surprisal_progress) { |
| return; |
| } |
|
|
| const stats = columnData.stats; |
| const isDiffColumn = modelDiffMode && columnData.diffStats && !isBaseColumn(id); |
| const safeId = toSafeId(id); |
|
|
| const mergedTokens = columnData.enhancedResult?.bpeBpeMergedTokens; |
| const histogramTokenSurprisals = |
| mergedTokens && mergedTokens.length > 0 |
| ? calculateMergedTokenSurprisals(mergedTokens) |
| : stats.tokenSurprisals; |
| const histogramTokenAvg = histogramTokenSurprisals.length > 0 ? computeAverage(histogramTokenSurprisals) : null; |
| const histogramTokenP90 = histogramTokenSurprisals.length > 0 ? computeP90(histogramTokenSurprisals) : null; |
|
|
| |
| |
| const tokenHistogramConfig = getTokenSurprisalHistogramConfig(); |
| columnData.histograms.stats_frac.update({ |
| ...tokenHistogramConfig, |
| data: histogramTokenSurprisals, |
| colorScale: tokenSurprisalColorScale, |
| averageValue: histogramTokenAvg ?? undefined, |
| p90Value: histogramTokenP90 ?? undefined, |
| p90Label: tokenHistogramConfig.averageLabel, |
| }); |
|
|
| |
| const tokenTitleElement = document.getElementById(`token_histogram_title_${safeId}`); |
| if (tokenTitleElement) { |
| tokenTitleElement.textContent = tokenHistogramConfig.label; |
| } |
|
|
| |
| if (isDiffColumn && columnData.diffStats) { |
| |
| const deltaByteSurprisals = columnData.diffStats.deltaByteSurprisals; |
| |
| |
| const deltaAverage = deltaByteSurprisals.length > 0 |
| ? deltaByteSurprisals.reduce((sum, val) => sum + val, 0) / deltaByteSurprisals.length |
| : 0; |
| |
| const deltaByteSurprisalConfig = getDeltaByteSurprisalHistogramConfig(); |
| columnData.histograms.stats_byte_frac.update({ |
| ...deltaByteSurprisalConfig, |
| data: deltaByteSurprisals, |
| colorScale: getDiffColor, |
| averageValue: deltaAverage, |
| }); |
| |
| |
| const titleElement = document.getElementById(`byte_histogram_title_${safeId}`); |
| if (titleElement) { |
| titleElement.textContent = deltaByteSurprisalConfig.label; |
| } |
| } else { |
| |
| |
| const byteSurprisalConfig = getByteSurprisalHistogramConfig(); |
| columnData.histograms.stats_byte_frac.update({ |
| ...byteSurprisalConfig, |
| data: stats.byteSurprisals, |
| colorScale: byteSurprisalColorScale, |
| averageValue: stats.byteAverage ?? undefined, |
| }); |
| |
| |
| const titleElement = document.getElementById(`byte_histogram_title_${safeId}`); |
| if (titleElement) { |
| titleElement.textContent = byteSurprisalConfig.label; |
| } |
| } |
|
|
| |
| if (histogramTokenSurprisals.length > 0) { |
| const surprisalProgressConfig = getSurprisalProgressConfig(); |
| columnData.histograms.stats_surprisal_progress.update({ |
| ...surprisalProgressConfig, |
| data: histogramTokenSurprisals, |
| }); |
|
|
| |
| const surprisalProgressTitleElement = document.getElementById(`surprisal_progress_title_${safeId}`); |
| if (surprisalProgressTitleElement && surprisalProgressConfig.label) { |
| surprisalProgressTitleElement.textContent = surprisalProgressConfig.label; |
| } |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| const updateMetricsForColumn = (id: string, stats: TextStats | null, modelName?: string | null | undefined) => { |
| const safeId = toSafeId(id); |
| const metrics = d3.select(`#text_metrics_${safeId}`); |
| const metricBytes = d3.select(`#metric_bytes_${safeId}`); |
| const metricChars = d3.select(`#metric_chars_${safeId}`); |
| const metricTokens = d3.select(`#metric_tokens_${safeId}`); |
| const metricTotalSurprisal = d3.select(`#metric_total_surprisal_${safeId}`); |
| const metricModel = d3.select(`#metric_model_${safeId}`); |
|
|
| if (metrics.empty() || !validateMetricsElements(metricBytes, metricChars, metricTokens, metricTotalSurprisal, metricModel)) { |
| return; |
| } |
|
|
| if (!stats) { |
| metrics.classed('is-hidden', true); |
| |
| metricModel.classed('is-hidden', true); |
| return; |
| } |
|
|
| |
| updateBasicMetrics(metricBytes, metricChars, metricTokens, stats); |
| |
| |
| const columnData = columnsData.get(id); |
| let diffMode: DiffModeConfig | undefined; |
| if (modelDiffMode && columnData && columnData.diffStats && !isBaseColumn(id)) { |
| |
| const delta = columnData.diffStats.deltaTotalSurprisal; |
| const baseId = getBaseColumnId(); |
| const baseData = baseId ? columnsData.get(baseId) : null; |
| const baseTotalSurprisal = baseData?.stats?.totalSurprisal; |
| diffMode = { |
| delta, |
| baseTotalSurprisal |
| }; |
| } |
| |
| |
| updateTotalSurprisal(metricTotalSurprisal, stats, totalSurprisalFormat, diffMode); |
|
|
| |
| updateModel(metricModel, modelName); |
| metricModel.classed('is-hidden', false); |
|
|
| |
| metrics.classed('is-hidden', false); |
| }; |
|
|
| |
| const showErrorForColumn = (id: string, error: string | null) => { |
| const safeId = toSafeId(id); |
| const errorDiv = d3.select(`#error_${safeId}`); |
| const statsDiv = d3.select(`#stats_demo_${safeId}`); |
| const metricsDiv = d3.select(`#text_metrics_${safeId}`); |
|
|
| if (errorDiv.empty()) { |
| return; |
| } |
|
|
| if (error) { |
| errorDiv.text(error).style('display', null); |
| statsDiv.style('display', 'none'); |
| |
| if (!metricsDiv.empty()) { |
| metricsDiv.classed('is-hidden', true); |
| } |
| } else { |
| errorDiv.style('display', 'none'); |
| statsDiv.style('display', null); |
| } |
| }; |
|
|
| |
| |
| const loadDemoForColumn = async (id: string): Promise<void> => { |
| const columnData = columnsData.get(id); |
| if (!columnData) { |
| console.error(`找不到ID为 ${id} 的列数据`); |
| return; |
| } |
|
|
| try { |
| |
| let response: AnalysisData; |
| if (columnData.data) { |
| response = columnData.data; |
| } else { |
| |
| const result = await demoResourceLoader.load(columnData.demoPath); |
| |
| if (!result.success || !result.data) { |
| columnData.error = tr(result.message || 'Load failed'); |
| showErrorForColumn(id, columnData.error); |
| updateModelDiffModeAvailability(); |
| return; |
| } |
|
|
| response = result.data; |
| } |
| const enhancedResult = processDemoData(response); |
| const safeText = response.request.text; |
| const textStats = calculateTextStats(enhancedResult, safeText); |
|
|
| columnData.data = response; |
| columnData.enhancedResult = enhancedResult; |
| columnData.stats = textStats; |
| columnData.error = null; |
| |
| columnData.originalText = safeText; |
|
|
| |
| showErrorForColumn(id, null); |
|
|
| |
| const resultModel = response.result.model; |
| updateMetricsForColumn(id, textStats, resultModel); |
|
|
| |
| renderStatsForColumn(id, columnData); |
|
|
| |
| if (modelDiffMode) { |
| |
| recalculateAllDiffStats(); |
| |
| |
| columnsData.forEach((colData, colId) => { |
| if (colData.stats) { |
| const resultModel = colData.data.result.model; |
| updateMetricsForColumn(colId, colData.stats, resultModel); |
| renderStatsForColumn(colId, colData); |
| } |
| }); |
| |
| if (!columnData.lmfInstance) { |
| initLMFForColumn(id, columnData); |
| } else { |
| |
| const isDiffColumn = columnData.diffStats && !isBaseColumn(id); |
| if (isDiffColumn && columnData.diffStats) { |
| columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals); |
| } else { |
| columnData.lmfInstance.setDiffMode(false, []); |
| } |
| columnData.lmfInstance.update(enhancedResult); |
| } |
| } else if (showTextRender) { |
| |
| if (!columnData.lmfInstance) { |
| initLMFForColumn(id, columnData); |
| } else { |
| columnData.lmfInstance.update(enhancedResult); |
| } |
| } |
|
|
| } catch (err) { |
| console.error(`加载 demo ${columnData.demoPath} 失败:`, err); |
| columnData.error = err instanceof Error ? err.message : tr('Load failed'); |
| showErrorForColumn(id, columnData.error); |
| } finally { |
| |
| updateModelDiffModeAvailability(); |
| } |
| }; |
|
|
| |
| const initializeColumnVisualizations = (id: string, columnData: DemoColumnData): void => { |
| const safeId = toSafeId(id); |
| const statsFracId = `#stats_frac_${safeId}`; |
| const statsByteFracId = `#stats_byte_frac_${safeId}`; |
| const statsProgressId = `#stats_surprisal_progress_${safeId}`; |
|
|
| |
| columnData.histograms.stats_frac = new Histogram( |
| d3.select(statsFracId), |
| eventHandler, |
| { width: 400, height: 200 } |
| ); |
|
|
| columnData.histograms.stats_byte_frac = new Histogram( |
| d3.select(statsByteFracId), |
| eventHandler, |
| { width: 400, height: 200 } |
| ); |
|
|
| |
| columnData.histograms.stats_surprisal_progress = new ScatterPlot( |
| d3.select(statsProgressId), |
| eventHandler, |
| { width: 400, height: 200 } |
| ); |
|
|
| |
| if (modelDiffMode || showTextRender) { |
| initLMFForColumn(id, columnData); |
| } |
| }; |
|
|
| |
| const initLMFForColumn = (id: string, columnData: DemoColumnData): void => { |
| const safeId = toSafeId(id); |
| const textRenderId = `#text_render_${safeId}`; |
| const textRenderContainer = d3.select(textRenderId); |
| |
| if (textRenderContainer.empty()) { |
| console.error(`找不到文本渲染容器: ${textRenderId}`); |
| return; |
| } |
|
|
| |
| |
| const shouldShow = modelDiffMode || showTextRender; |
| textRenderContainer.classed('is-hidden', !shouldShow); |
|
|
| |
| if (columnData.lmfInstance) { |
| columnData.lmfInstance.destroy(); |
| } |
|
|
| |
| columnData.lmfInstance = new GLTR_Text_Box(textRenderContainer, eventHandler); |
| |
| |
| columnData.lmfInstance.updateOptions({ |
| gltrMode: GLTR_Mode.fract_p, |
| enableRenderAnimation: false, |
| enableMinimap: false |
| }, true); |
|
|
| |
| const isDiffColumn = modelDiffMode && columnData.diffStats && !isBaseColumn(id); |
| if (isDiffColumn && columnData.diffStats) { |
| columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals); |
| } else { |
| columnData.lmfInstance.setDiffMode(false, []); |
| } |
|
|
| |
| let enhancedResult = columnData.enhancedResult; |
| if (!enhancedResult && columnData.data) { |
| enhancedResult = processDemoData(columnData.data); |
| columnData.enhancedResult = enhancedResult; |
| } |
| if (enhancedResult) { |
| columnData.lmfInstance.update(enhancedResult); |
| } |
| }; |
|
|
| |
| const parseHistogramSource = (source?: string): { safeId: string; histogramType: 'token' | 'byte' } | null => { |
| if (!source) { |
| return null; |
| } |
|
|
| const bytePrefix = 'stats_byte_frac'; |
| const tokenPrefix = 'stats_frac'; |
|
|
| if (source.startsWith(bytePrefix)) { |
| const safeId = source.substring(bytePrefix.length).replace(/^_/, ''); |
| return safeId ? { safeId, histogramType: 'byte' } : null; |
| } |
|
|
| if (source.startsWith(tokenPrefix)) { |
| const safeId = source.substring(tokenPrefix.length).replace(/^_/, ''); |
| return safeId ? { safeId, histogramType: 'token' } : null; |
| } |
|
|
| return null; |
| }; |
|
|
| |
| const findColumnBySafeId = (safeId: string): { id: string; columnData: DemoColumnData } | null => { |
| if (!safeId) { |
| return null; |
| } |
|
|
| for (const [id, columnData] of columnsData.entries()) { |
| if (toSafeId(id) === safeId) { |
| return { id, columnData }; |
| } |
| } |
|
|
| return null; |
| }; |
|
|
| |
| const handleHistogramBinClick = (ev: HistogramBinClickEvent): void => { |
| const parsed = parseHistogramSource(ev?.source); |
| if (!parsed) { |
| return; |
| } |
|
|
| const columnEntry = findColumnBySafeId(parsed.safeId); |
| if (!columnEntry) { |
| return; |
| } |
|
|
| const { columnData } = columnEntry; |
|
|
| |
| |
| if (modelDiffMode) { |
| |
| if (!isBaseColumn(columnData.id) || !columnData.lmfInstance) { |
| return; |
| } |
| } else { |
| |
| if (!columnData.lmfInstance) { |
| return; |
| } |
| } |
|
|
| const { stats_frac, stats_byte_frac } = columnData.histograms; |
|
|
| let enhancedResult = columnData.enhancedResult; |
| if (!enhancedResult && columnData.data) { |
| enhancedResult = processDemoData(columnData.data); |
| columnData.enhancedResult = enhancedResult; |
| } |
|
|
| if (!enhancedResult) { |
| return; |
| } |
|
|
| |
| if (ev.binIndex === -1) { |
| stats_frac?.clearSelection(); |
| stats_byte_frac?.clearSelection(); |
| columnData.lmfInstance.clearHighlight(); |
| return; |
| } |
|
|
| |
| if (parsed.histogramType === 'byte') { |
| stats_frac?.clearSelection(); |
| } else { |
| stats_byte_frac?.clearSelection(); |
| } |
|
|
| |
| const { x0, x1 } = ev; |
| const { indices, style } = calculateHighlights(parsed.histogramType, x0, x1, ev.binIndex, ev.no_bins, enhancedResult); |
| |
| |
| columnData.lmfInstance.setHighlightedIndices(indices, style); |
| }; |
|
|
| |
| eventHandler.bind(GLTR_Text_Box.events.tokenHovered, (ev: GLTR_HoverEvent) => { |
| if (ev.hovered) { |
| toolTip.updateData(ev.d, ev.event); |
| } else { |
| toolTip.visibility = false; |
| } |
| }); |
|
|
| |
| eventHandler.bind(Histogram.events.binClicked, handleHistogramBinClick); |
|
|
| |
| |
| |
| |
| const updateModelDiffModeAvailability = (): void => { |
| const hasLoadingDemos = Array.from(columnsData.values()) |
| .some(col => !col.data && !col.error); |
| |
| const checkbox = d3.select<HTMLInputElement, any>('#model_diff_mode_toggle').node(); |
| if (checkbox) { |
| checkbox.disabled = hasLoadingDemos; |
| if (hasLoadingDemos) { |
| checkbox.title = tr('Please wait for all demos to load'); |
| } else { |
| checkbox.title = ''; |
| } |
| } |
| }; |
|
|
| |
| const checkTextConsistency = (): { consistent: boolean; referenceText?: string; inconsistentDemos?: string[] } => { |
| const texts = new Map<string, string[]>(); |
| |
| |
| columnsData.forEach((columnData, id) => { |
| let text: string | undefined; |
| |
| |
| if (columnData.originalText !== undefined) { |
| text = columnData.originalText; |
| } else if (columnData.data) { |
| text = columnData.data.request.text; |
| } |
| |
| if (text !== undefined) { |
| if (!texts.has(text)) { |
| texts.set(text, []); |
| } |
| texts.get(text)!.push(columnData.demoName); |
| } |
| }); |
|
|
| if (texts.size === 0) { |
| |
| return { consistent: true }; |
| } |
|
|
| if (texts.size === 1) { |
| |
| const referenceText = Array.from(texts.keys())[0]; |
| return { consistent: true, referenceText }; |
| } |
|
|
| |
| const inconsistentDemos: string[] = []; |
| texts.forEach((demos) => { |
| inconsistentDemos.push(...demos); |
| }); |
|
|
| return { consistent: false, inconsistentDemos }; |
| }; |
|
|
| |
| const cleanupModelDiffMode = (): void => { |
| columnsData.forEach((columnData) => { |
| |
| |
| if (columnData.lmfInstance) { |
| columnData.lmfInstance.setDiffMode(false, []); |
| } |
| |
| |
| |
| }); |
| }; |
|
|
| |
| const enableModelDiffMode = (): void => { |
| |
| const consistency = checkTextConsistency(); |
| |
| if (!consistency.consistent) { |
| showAlertDialog(tr('Error'), tr('Cannot enable model diff mode: current demos have inconsistent source text')); |
| |
| const checkbox = d3.select<HTMLInputElement, any>('#model_diff_mode_toggle').node(); |
| if (checkbox) { |
| checkbox.checked = false; |
| } |
| return; |
| } |
|
|
| modelDiffMode = true; |
|
|
| |
| syncStateToURL(); |
|
|
| |
| updateShowTextRenderCheckbox(); |
|
|
| |
| recalculateAllDiffStats(); |
|
|
| |
| columnsData.forEach((columnData, id) => { |
| if (columnData.stats) { |
| |
| const resultModel = columnData.data.result.model; |
| updateMetricsForColumn(id, columnData.stats, resultModel); |
| |
| |
| renderStatsForColumn(id, columnData); |
| } |
| }); |
|
|
| |
| columnsData.forEach((columnData, id) => { |
| |
| if (!columnData.lmfInstance) { |
| initLMFForColumn(id, columnData); |
| } else { |
| const safeId = toSafeId(id); |
| const textRenderContainer = d3.select(`#text_render_${safeId}`); |
| |
| if (!textRenderContainer.empty()) { |
| |
| textRenderContainer.classed('is-hidden', false); |
|
|
| |
| const isDiffColumn = columnData.diffStats && !isBaseColumn(id); |
| if (isDiffColumn && columnData.diffStats) { |
| columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals); |
| } else { |
| columnData.lmfInstance.setDiffMode(false, []); |
| } |
| } |
| } |
| }); |
| }; |
|
|
| |
| const disableModelDiffMode = (): void => { |
| modelDiffMode = false; |
| |
| |
| syncStateToURL(); |
| |
| |
| columnsData.forEach((columnData) => { |
| columnData.diffStats = null; |
| }); |
| |
| |
| cleanupModelDiffMode(); |
| |
| |
| columnsData.forEach((columnData, id) => { |
| if (columnData.stats) { |
| |
| const resultModel = columnData.data.result.model; |
| updateMetricsForColumn(id, columnData.stats, resultModel); |
| |
| |
| renderStatsForColumn(id, columnData); |
| } |
| }); |
| |
| |
| updateShowTextRenderCheckbox(); |
| |
| |
| updateTextRenderVisibility(); |
| }; |
|
|
| |
| |
| |
| const updateTextRenderVisibility = (): void => { |
| columnsData.forEach((columnData, id) => { |
| const safeId = toSafeId(id); |
| const textRenderContainer = d3.select(`#text_render_${safeId}`); |
| |
| if (!textRenderContainer.empty()) { |
| |
| const shouldShow = modelDiffMode || showTextRender; |
| textRenderContainer.classed('is-hidden', !shouldShow); |
| |
| |
| if (shouldShow && !columnData.lmfInstance) { |
| initLMFForColumn(id, columnData); |
| } |
| |
| else if (!shouldShow && columnData.lmfInstance) { |
| columnData.lmfInstance.destroy(); |
| columnData.lmfInstance = undefined; |
| } |
| } |
| }); |
| |
| |
| syncStateToURL(); |
| }; |
|
|
| |
| |
| |
| const updateShowTextRenderCheckbox = (): void => { |
| const checkbox = d3.select<HTMLInputElement, any>('#show_text_render_toggle').node(); |
| if (checkbox) { |
| |
| if (modelDiffMode) { |
| checkbox.checked = true; |
| checkbox.disabled = true; |
| } else { |
| |
| checkbox.disabled = false; |
| checkbox.checked = showTextRender; |
| } |
| } |
| }; |
|
|
| |
| |
| |
| |
| const handleLocalFileSelection = async (): Promise<void> => { |
| try { |
| |
| const result = await localFileIO.import(true); |
| |
| if (!result.success) { |
| |
| if (!result.cancelled && result.message) { |
| showAlertDialog(tr('Error'), tr(result.message || 'Import failed')); |
| } |
| return; |
| } |
| |
| |
| const filesToProcess = result.files || []; |
| |
| if (filesToProcess.length === 0) { |
| showAlertDialog(tr('Error'), tr('No file selected')); |
| return; |
| } |
| |
| |
| if (result.message) { |
| |
| |
| console.warn('File reading warning:', result.message); |
| } |
| |
| |
| const errors: string[] = []; |
| |
| for (const file of filesToProcess) { |
| try { |
| |
| const saveResult = await localDemoCache.save(file.data, { |
| name: file.filename |
| }); |
| |
| if (!saveResult.success || !saveResult.hash) { |
| errors.push(`${file.filename}: ${tr(saveResult.message || 'Failed to save to cache')}`); |
| continue; |
| } |
| |
| |
| const identifier = DemoResourceLoader.createLocalIdentifier( |
| file.filename, |
| saveResult.hash |
| ); |
| |
| |
| await addSingleColumn(identifier); |
| |
| } catch (error) { |
| const message = extractErrorMessage(error, tr('Processing failed')); |
| errors.push(`${file.filename}: ${message}`); |
| } |
| } |
| |
| |
| if (errors.length > 0) { |
| const successCount = filesToProcess.length - errors.length; |
| const errorMessage = errors.length === filesToProcess.length |
| ? tr('All files import failed:') + `\n${errors.join('\n')}` |
| : tr('Some files import failed') + ` (${tr('Success')} ${successCount}/${filesToProcess.length}):\n${errors.join('\n')}`; |
| showAlertDialog(tr('Import Result'), errorMessage); |
| } |
| |
| |
| if (filesToProcess.length > errors.length) { |
| syncStateToURL(); |
| } |
| |
| } catch (error) { |
| const message = extractErrorMessage(error, tr('Failed to add local file')); |
| console.error('Failed to add local file:', error); |
| showAlertDialog(tr('Error'), message); |
| } |
| }; |
|
|
| |
| |
| const addSingleColumn = async (resourceIdentifier: string): Promise<void> => { |
| |
| const isLocal = DemoResourceLoader.isLocalResource(resourceIdentifier); |
| const id = isLocal |
| ? resourceIdentifier |
| : normalizeDemoPath(resourceIdentifier); |
| |
| |
| if (columnsData.has(id)) { |
| showAlertDialog(tr('Info'), tr('This demo is already in the comparison list')); |
| return; |
| } |
| |
| |
| const demoName = isLocal |
| ? DemoResourceLoader.extractLocalInfo(resourceIdentifier).filename |
| : getDemoName(resourceIdentifier); |
|
|
| |
| let preloadedData: AnalysisData | null = null; |
|
|
| |
| if (modelDiffMode) { |
| try { |
| |
| const result = await demoResourceLoader.load(resourceIdentifier); |
| |
| if (!result.success || !result.data) { |
| showAlertDialog(tr('Error'), tr(result.message || 'Load failed')); |
| return; |
| } |
| |
| const preloadText = result.data.request.text; |
| |
| |
| const consistency = checkTextConsistency(); |
| |
| if (consistency.consistent && consistency.referenceText !== undefined) { |
| if (preloadText !== consistency.referenceText) { |
| |
| showAlertDialog(tr('Error'), tr('Cannot add demo, source text inconsistent with existing demos:') + `\n${demoName}`); |
| return; |
| } |
| } |
|
|
| |
| preloadedData = result.data; |
| } catch (err) { |
| console.error(`预检查 demo ${resourceIdentifier} 失败:`, err); |
| const message = extractErrorMessage(err, tr('Precheck failed')); |
| showAlertDialog(tr('Error'), tr('Demo precheck failed:') + ` ${message}`); |
| return; |
| } |
| } |
| |
| |
| const columnData: DemoColumnData = { |
| id, |
| demoPath: resourceIdentifier, |
| demoName, |
| data: preloadedData, |
| enhancedResult: null, |
| stats: null, |
| error: null, |
| originalText: undefined, |
| lmfInstance: undefined, |
| histograms: { |
| stats_frac: null, |
| stats_byte_frac: null, |
| stats_surprisal_progress: null |
| } |
| }; |
| |
| |
| const columnHTML = createColumnHTML(id, demoName); |
| const containerNode = container.node(); |
| if (!containerNode || !(containerNode instanceof Element)) { |
| throw new Error('Container node is not an Element'); |
| } |
| const columnElement = document.createElement('div'); |
| containerNode.appendChild(columnElement); |
| const columnNode = d3.select(columnElement); |
| columnNode.html(columnHTML); |
| |
| |
| initializeColumnVisualizations(id, columnData); |
| |
| |
| columnsData.set(id, columnData); |
| |
| |
| await loadDemoForColumn(id); |
| }; |
|
|
| |
| const clearAllColumns = (): void => { |
| |
| columnsData.forEach((columnData) => { |
| if (columnData.lmfInstance) { |
| columnData.lmfInstance.destroy(); |
| columnData.lmfInstance = undefined; |
| } |
| }); |
| |
| |
| columnsData.clear(); |
| |
| |
| container.selectAll('.compare-column').remove(); |
| |
| |
| const currentParams = URLHandler.parameters; |
| delete currentParams['demos']; |
| URLHandler.updateUrl(currentParams, false); |
| |
| |
| updateModelDiffModeAvailability(); |
| |
| |
| }; |
|
|
| |
| const syncStateToURL = (): void => { |
| const demoPaths = Array.from(columnsData.values()) |
| .map(col => col.demoPath) |
| .filter(path => path != null && path !== ''); |
| |
| const currentParams = URLHandler.parameters; |
| |
| |
| delete currentParams['showTextRender']; |
| delete currentParams['modelDiffMode']; |
| delete currentParams['demos']; |
| |
| |
| if (showTextRender) { |
| currentParams['showTextRender'] = '1'; |
| } |
| |
| if (modelDiffMode) { |
| currentParams['modelDiffMode'] = '1'; |
| } |
| |
| if (demoPaths.length > 0) { |
| |
| currentParams['demos'] = demoPaths.join(','); |
| } |
| |
| URLHandler.updateUrl(currentParams, false); |
| }; |
|
|
| |
| const initializeColumns = async (): Promise<void> => { |
| if (demoPaths.length === 0) { |
| |
| return; |
| } |
| |
| |
| try { |
| for (const path of demoPaths) { |
| await addSingleColumn(path); |
| } |
|
|
| |
| const errors = Array.from(columnsData.values()) |
| .filter(col => col.error) |
| .map(col => `${col.demoName}: ${col.error}`); |
| if (errors.length > 0) { |
| showAlertDialog(tr('Some demos failed to load'), errors.join('\n')); |
| } |
| |
| |
| updateModelDiffModeAvailability(); |
| } catch (err) { |
| console.error('Error loading demos:', err); |
| showAlertDialog(tr('Error'), tr('Error loading demos, please check console for details.')); |
| |
| updateModelDiffModeAvailability(); |
| } |
| }; |
|
|
| |
| const themeManager = initThemeManager({ |
| onThemeChange: () => { |
| columnsData.forEach((col) => { |
| if (col.data && col.stats) { |
| renderStatsForColumn(col.id, col); |
| } |
| requestAnimationFrame(() => col.lmfInstance?.reRenderCurrent()); |
| }); |
| } |
| }); |
|
|
| |
| |
| |
| const getExistingDemoIds = (): Set<string> => { |
| return new Set( |
| Array.from(columnsData.values()) |
| .map(col => { |
| |
| return DemoResourceLoader.isLocalResource(col.demoPath) |
| ? col.demoPath |
| : normalizeDemoPath(col.demoPath); |
| }) |
| ); |
| }; |
|
|
| |
| const showDemoSelectorDialog = (): void => { |
| const existingDemoIds = getExistingDemoIds(); |
|
|
| showDialog({ |
| title: tr('Select Demo'), |
| |
| |
| width: 'clamp(300px, 90vw, 800px)', |
| |
| height: 'max(400px, 85vh)', |
| content: (dialog, setConfirmButtonState) => { |
| |
| const demoContainer = dialog.append('div') |
| .attr('class', 'demo-selector-container'); |
|
|
| |
| const demoSection = demoContainer.append('section') |
| .attr('class', 'demo-section'); |
|
|
| const demoHeader = demoSection.append('div') |
| .attr('class', 'demo-header'); |
|
|
| |
| const leftSection = demoHeader.append('div') |
| .style('display', 'flex') |
| .style('align-items', 'center') |
| .style('gap', '8px'); |
|
|
| leftSection.append('span') |
| .text(tr('Select demo to add:')); |
|
|
| const refreshBtn = leftSection.append('button') |
| .attr('class', 'refresh-btn') |
| .attr('title', tr('Refresh demo list')) |
| .text('↻'); |
|
|
| const loadingIndicator = leftSection.append('span') |
| .attr('class', 'demos-loading') |
| .style('display', 'none') |
| .text(tr('Refreshing...')); |
|
|
| |
| const headerActions = demoHeader.append('div') |
| .attr('class', 'demo-header-actions'); |
|
|
| headerActions.append('button') |
| .attr('class', 'btn btn-primary') |
| .style('padding', '8px 16px') |
| .style('cursor', 'pointer') |
| .text(tr('Select local')) |
| .on('click', async () => { |
| |
| const overlay = d3.select('.dialog-overlay'); |
| if (!overlay.empty()) { |
| overlay.remove(); |
| } |
| |
| |
| await handleLocalFileSelection(); |
| }); |
|
|
| const demosContainer = demoSection.append('div') |
| .attr('class', 'demos'); |
|
|
| |
| const selectorDemoManager = initDemoManager({ |
| api, |
| enableDemo: true, |
| containerSelector: '.demo-selector-container .demos', |
| loaderSelector: '.demo-selector-container .demos-loading', |
| refreshSelector: '.demo-selector-container .refresh-btn', |
| forceMultiSelect: true, |
| disableFolderOperations: true, |
| disableClickLoad: true, |
| onDemoLoaded: () => { |
| |
| }, |
| onTextPrefill: () => {}, |
| onDemoLoading: () => {}, |
| onRefreshStart: () => { |
| loadingIndicator.style('display', null); |
| }, |
| onRefreshEnd: () => { |
| loadingIndicator.style('display', 'none'); |
| |
| markExistingDemos(); |
| }, |
| onSelectionChange: (selectedCount: number) => { |
| |
| if (setConfirmButtonState) { |
| const hasSelection = selectedCount > 0; |
| setConfirmButtonState(hasSelection); |
| } |
| }, |
| }); |
|
|
| |
| const markExistingDemos = () => { |
| const demoItems = d3.selectAll<HTMLDivElement, any>('.demo-selector-container .demo-item'); |
| demoItems.each(function(d) { |
| const demoItem = d3.select(this); |
| const checkbox = demoItem.select<HTMLInputElement>('.demo-checkbox-inline'); |
| const demoBtn = demoItem.select('.demoBtn'); |
| |
| if (!checkbox.empty() && !demoBtn.empty() && d) { |
| |
| |
| const itemPath = d.path || ''; |
| const normalizedPath = normalizeDemoPath(itemPath); |
| |
| if (existingDemoIds.has(normalizedPath)) { |
| |
| const checkboxNode = checkbox.node(); |
| if (checkboxNode) { |
| checkboxNode.disabled = true; |
| checkboxNode.checked = false; |
| } |
| |
| |
| demoItem.classed('demo-item-disabled', true); |
| demoBtn.classed('demo-disabled', true); |
| } |
| |
| |
| } |
| }); |
| }; |
|
|
| return { |
| getValue: () => { |
| return selectorDemoManager.getSelectedPaths(); |
| }, |
| validate: () => { |
| return selectorDemoManager.getSelectedPaths().length > 0; |
| } |
| }; |
| }, |
| onConfirm: (selectedPaths: string[]) => { |
| if (!selectedPaths || selectedPaths.length === 0) { |
| showAlertDialog(tr('Info'), tr('Please select at least one demo')); |
| return; |
| } |
| |
| |
| (async () => { |
| try { |
| for (const path of selectedPaths) { |
| await wrappedAddSingleColumn(path); |
| } |
| |
| syncStateToURL(); |
| } catch (err) { |
| console.error('Failed to add demo:', err); |
| } |
| })(); |
| }, |
| confirmText: tr('Confirm'), |
| cancelText: tr('Cancel') |
| }); |
| }; |
|
|
| |
| let editMode = false; |
| const wrapper = d3.select('.compare-wrapper'); |
|
|
| |
| const toggleEditMode = (): void => { |
| editMode = !editMode; |
| if (editMode) { |
| wrapper.classed('edit-mode', true); |
| } else { |
| wrapper.classed('edit-mode', false); |
| } |
| updateEditButtonsState(); |
| }; |
|
|
| |
| const updateEditButtonsState = (): void => { |
| const columns = container.selectAll<HTMLElement, any>('.compare-column'); |
| const columnNodes = columns.nodes(); |
| |
| columns.each(function(d, i) { |
| const columnElement = d3.select(this); |
| const moveToFirstBtn = columnElement.select('.move-to-first-btn'); |
| const moveLeftBtn = columnElement.select('.move-left-btn'); |
| const moveRightBtn = columnElement.select('.move-right-btn'); |
| const moveToLastBtn = columnElement.select('.move-to-last-btn'); |
| |
| |
| const isFirst = i === 0; |
| moveToFirstBtn.property('disabled', isFirst); |
| moveLeftBtn.property('disabled', isFirst); |
| |
| |
| const isLast = i === columnNodes.length - 1; |
| moveRightBtn.property('disabled', isLast); |
| moveToLastBtn.property('disabled', isLast); |
| }); |
| }; |
|
|
| |
| const syncColumnOrder = (): void => { |
| |
| const newAllColumns = Array.from(container.selectAll('.compare-column').nodes()) as HTMLElement[]; |
| const newColumnIds = newAllColumns.map(node => { |
| const element = node as HTMLElement; |
| return element.getAttribute('data-column-id') || ''; |
| }).filter(id => id && columnsData.has(id)); |
|
|
| |
| const newColumnsData = new Map<string, DemoColumnData>(); |
| newColumnIds.forEach(id => { |
| const data = columnsData.get(id); |
| if (data) { |
| newColumnsData.set(id, data); |
| } |
| }); |
| columnsData.clear(); |
| newColumnsData.forEach((value, key) => { |
| columnsData.set(key, value); |
| }); |
|
|
| |
| syncStateToURL(); |
|
|
| |
| updateEditButtonsState(); |
| |
| |
| if (modelDiffMode) { |
| recalculateAllDiffStats(); |
| |
| |
| columnsData.forEach((columnData, id) => { |
| if (columnData.stats) { |
| const resultModel = columnData.data.result.model; |
| updateMetricsForColumn(id, columnData.stats, resultModel); |
| renderStatsForColumn(id, columnData); |
| } |
| |
| |
| if (columnData.lmfInstance) { |
| const isDiffColumn = columnData.diffStats && !isBaseColumn(id); |
| if (isDiffColumn && columnData.diffStats) { |
| columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals); |
| } else { |
| columnData.lmfInstance.setDiffMode(false, []); |
| } |
| } |
| }); |
| } |
| }; |
|
|
| |
| const moveColumn = (columnId: string, direction: 'left' | 'right' | 'first' | 'last'): void => { |
| const columnElement = container.select(`[data-column-id="${columnId}"]`); |
| if (columnElement.empty()) { |
| return; |
| } |
|
|
| const columnNode = columnElement.node() as HTMLElement | null; |
| if (!columnNode) { |
| return; |
| } |
|
|
| |
| const allColumns = Array.from(container.selectAll('.compare-column').nodes()) as HTMLElement[]; |
| const currentIndex = allColumns.indexOf(columnNode); |
| |
| if (currentIndex === -1) { |
| return; |
| } |
|
|
| |
| const containerNode = container.node() as HTMLElement | null; |
| if (!containerNode) { |
| return; |
| } |
|
|
| |
| const columnParent = columnNode.parentElement; |
| if (!columnParent) { |
| return; |
| } |
|
|
| |
| if (direction === 'first') { |
| |
| if (currentIndex === 0) { |
| return; |
| } |
| const firstColumnParent = allColumns[0].parentElement; |
| if (firstColumnParent) { |
| containerNode.insertBefore(columnParent, firstColumnParent); |
| } |
| } else if (direction === 'last') { |
| |
| if (currentIndex === allColumns.length - 1) { |
| return; |
| } |
| containerNode.appendChild(columnParent); |
| } else if (direction === 'left') { |
| |
| if (currentIndex === 0) { |
| return; |
| } |
| const targetIndex = currentIndex - 1; |
| const targetColumn = allColumns[targetIndex]; |
| if (!targetColumn) { |
| return; |
| } |
| const targetParent = targetColumn.parentElement; |
| if (!targetParent) { |
| return; |
| } |
| |
| if (columnParent === targetParent) { |
| console.error('DOM 结构异常:两个列在同一个父容器中'); |
| return; |
| } |
| containerNode.insertBefore(columnParent, targetParent); |
| } else { |
| |
| if (currentIndex === allColumns.length - 1) { |
| return; |
| } |
| const targetIndex = currentIndex + 1; |
| const targetColumn = allColumns[targetIndex]; |
| if (!targetColumn) { |
| return; |
| } |
| const targetParent = targetColumn.parentElement; |
| if (!targetParent) { |
| return; |
| } |
| |
| if (columnParent === targetParent) { |
| console.error('DOM 结构异常:两个列在同一个父容器中'); |
| return; |
| } |
| |
| if (targetParent.nextSibling) { |
| containerNode.insertBefore(columnParent, targetParent.nextSibling); |
| } else { |
| containerNode.appendChild(columnParent); |
| } |
| } |
|
|
| |
| syncColumnOrder(); |
| }; |
|
|
| |
| const deleteColumn = (columnId: string): void => { |
| const columnData = columnsData.get(columnId); |
| if (!columnData) { |
| return; |
| } |
|
|
| |
| const deletedIsBase = isBaseColumn(columnId); |
|
|
| |
| if (columnData.lmfInstance) { |
| columnData.lmfInstance.destroy(); |
| columnData.lmfInstance = undefined; |
| } |
|
|
| |
| |
| const columnElement = container.select(`[data-column-id="${columnId}"]`); |
| columnElement.remove(); |
|
|
| |
| columnsData.delete(columnId); |
|
|
| |
| syncStateToURL(); |
|
|
| |
| updateEditButtonsState(); |
| |
| |
| updateModelDiffModeAvailability(); |
|
|
| |
| if (modelDiffMode && deletedIsBase) { |
| recalculateAllDiffStats(); |
| |
| |
| columnsData.forEach((columnData, id) => { |
| if (columnData.stats) { |
| const resultModel = columnData.data.result.model; |
| updateMetricsForColumn(id, columnData.stats, resultModel); |
| renderStatsForColumn(id, columnData); |
| } |
| |
| |
| if (columnData.lmfInstance) { |
| const isDiffColumn = columnData.diffStats && !isBaseColumn(id); |
| if (isDiffColumn && columnData.diffStats) { |
| columnData.lmfInstance.setDiffMode(true, columnData.diffStats.deltaByteSurprisals); |
| } else { |
| columnData.lmfInstance.setDiffMode(false, []); |
| } |
| } |
| }); |
| } |
| }; |
|
|
| |
| container.on('click', function(event) { |
| |
| if (!editMode) { |
| return; |
| } |
| |
| const target = event.target as HTMLElement; |
| if (!target) { |
| return; |
| } |
|
|
| |
| const moveToFirstBtn = target.closest('.move-to-first-btn'); |
| const moveLeftBtn = target.closest('.move-left-btn'); |
| const moveRightBtn = target.closest('.move-right-btn'); |
| const moveToLastBtn = target.closest('.move-to-last-btn'); |
| const deleteBtn = target.closest('.delete-btn'); |
| |
| |
| if (moveToFirstBtn && (moveToFirstBtn as HTMLElement).hasAttribute('disabled')) { |
| return; |
| } |
| if (moveLeftBtn && (moveLeftBtn as HTMLElement).hasAttribute('disabled')) { |
| return; |
| } |
| if (moveRightBtn && (moveRightBtn as HTMLElement).hasAttribute('disabled')) { |
| return; |
| } |
| if (moveToLastBtn && (moveToLastBtn as HTMLElement).hasAttribute('disabled')) { |
| return; |
| } |
|
|
| const columnElement = target.closest('.compare-column'); |
| if (!columnElement) { |
| return; |
| } |
|
|
| const columnId = columnElement.getAttribute('data-column-id'); |
| if (!columnId) { |
| return; |
| } |
|
|
| if (moveToFirstBtn) { |
| moveColumn(columnId, 'first'); |
| } else if (moveLeftBtn) { |
| moveColumn(columnId, 'left'); |
| } else if (moveRightBtn) { |
| moveColumn(columnId, 'right'); |
| } else if (moveToLastBtn) { |
| moveColumn(columnId, 'last'); |
| } else if (deleteBtn) { |
| deleteColumn(columnId); |
| } |
| }); |
|
|
| |
| const editModeToggleBtn = d3.select('#edit_mode_toggle'); |
| const clearBtn = d3.select('#clear_demos_btn'); |
| const addBtn = d3.select('#add_demos_btn'); |
| const showTextRenderToggle = d3.select<HTMLInputElement, any>('#show_text_render_toggle'); |
| const modelDiffModeToggle = d3.select<HTMLInputElement, any>('#model_diff_mode_toggle'); |
|
|
| editModeToggleBtn.on('click', () => { |
| toggleEditMode(); |
| editModeToggleBtn.text(editMode ? tr('Finish editing') : tr('Edit')); |
| |
| editModeToggleBtn.classed('finish-edit', editMode); |
| }); |
|
|
| clearBtn.on('click', () => { |
| if (columnsData.size === 0) { |
| showAlertDialog(tr('Info'), tr('No demos to compare')); |
| return; |
| } |
| clearAllColumns(); |
| }); |
|
|
| addBtn.on('click', () => { |
| showDemoSelectorDialog(); |
| }); |
|
|
| |
| showTextRenderToggle.on('change', function() { |
| const checkbox = this as HTMLInputElement; |
| showTextRender = checkbox.checked; |
| updateTextRenderVisibility(); |
| |
| syncStateToURL(); |
| }); |
|
|
| |
| modelDiffModeToggle.on('change', function() { |
| const checkbox = this as HTMLInputElement; |
| if (checkbox.checked) { |
| enableModelDiffMode(); |
| } else { |
| disableModelDiffMode(); |
| } |
| }); |
|
|
| |
| const wrappedAddSingleColumn = async (demoPath: string): Promise<void> => { |
| await addSingleColumn(demoPath); |
| updateEditButtonsState(); |
| |
| |
| }; |
|
|
| |
| initI18n(); |
| document.title = tr(document.title); |
|
|
| |
| initializeColumns().then(() => { |
| updateEditButtonsState(); |
| updateShowTextRenderCheckbox(); |
| |
| |
| |
| const modelDiffModeCheckbox = d3.select<HTMLInputElement, any>('#model_diff_mode_toggle').node(); |
| if (modelDiffModeCheckbox) { |
| modelDiffModeCheckbox.checked = modelDiffMode; |
| } |
| |
| |
| if (modelDiffMode && columnsData.size > 0) { |
| enableModelDiffMode(); |
| } |
| |
| |
| const showTextRenderCheckbox = d3.select<HTMLInputElement, any>('#show_text_render_toggle').node(); |
| if (showTextRenderCheckbox) { |
| showTextRenderCheckbox.checked = showTextRender; |
| } |
| |
| |
| if (showTextRender) { |
| updateTextRenderVisibility(); |
| } |
| }); |
| }; |
|
|
|
|