import { VComponent } from "./VisComponent"; import { D3Sel } from "../utils/Util"; import { SimpleEventHandler } from "../utils/SimpleEventHandler"; import { tr } from "../lang/i18n-lite"; import * as d3 from "d3"; import { schemeDark2 } from "d3"; const averageNumberFormat = d3.format('.2f'); /** 1-2-5 decade 模式生成非线性 y 轴刻度,最多 maxTicks 个 */ function getNonLinearTickValues(maxCount: number, maxTicks = 10): number[] { if (maxCount <= 0) return [0]; const ticks: number[] = [0]; const base = [1, 2, 5]; let decade = 1; while (decade <= maxCount) { for (const b of base) { const v = b * decade; if (v <= maxCount) ticks.push(v); } decade *= 10; } if (ticks[ticks.length - 1] !== maxCount) ticks.push(maxCount); if (ticks.length <= maxTicks) return ticks; const result: number[] = []; for (let i = 0; i < maxTicks; i++) { const idx = Math.round((i / (maxTicks - 1)) * (ticks.length - 1)); result.push(ticks[idx]); } return [...new Set(result)].sort((a, b) => a - b); } /** 单边:固定值或 'auto'(从 data 解析) */ export type HistogramExtentBound = number | 'auto'; /** extent:'auto' 等价于 ['auto','auto'],支持双边独立配置 */ export type HistogramExtent = [HistogramExtentBound, HistogramExtentBound] | 'auto'; export type HistogramData = { data: number[], label?: string, no_bins: number, extent: HistogramExtent, colorScale: (value: number) => string, // 添加颜色 scale averageValue?: number, p90Value?: number, averageLabel?: string, p90Label?: string, showLeftInfinity?: boolean, showRightInfinity?: boolean, /** x轴刻度数字绘制间隔,0表示不跳过,1表示隔一个绘制一个(0,2,4...) */ xAxisTickSkip?: number, /** x轴刻度凑整:true=仅显示 step 整数倍处的标签(与 tickSkip 配合),false/undefined=显示全部 */ xAxisTickRound?: boolean; yScaleType?: 'linear' | 'sqrt' | 'log' // y轴尺度:linear 线性,sqrt 平方根,log 对数(指数刻度,从 0 开始) /** 拟合分布的每个 bin 期望计数,用于绘制横虚线标识(如指数噪声拟合) */ fitExpectedCounts?: number[]; /** 是否叠加 prob 曲线(共用 x 轴,左侧新 y 轴 0–1) */ showProbCurve?: boolean; /** 曲线数据:x=raw_score_normed,y=prob(0–1),P(signal) 按 findSignalThreshold 的 bin 分块估计,(obs-exp)/obs */ probCurveData?: { x: number[]; y: number[] }; /** 信号阈值竖线:归一化分数,用于 raw_score_normed 直方图 */ signalThreshold?: number | null; /** 信号阈值对应的分位数(0–100),用于 label 显示 τ = pXX */ signalThresholdPercentile?: number | null; } export type HistogramBinClickEvent = { binIndex: number; x0: number; x1: number; data: number[]; no_bins: number; // 直方图的bin数量 source?: string; // 直方图标识,用于区分不同的直方图实例 } export class Histogram extends VComponent { protected _current = { selectedBinIndex: null as number | null // 当前选中的bin索引 }; protected css_name = 'HistogramX'; protected options = { width: 200, height: 150, margin_top: 10, margin_bottom: 21, numberFormat: d3.format('.3') }; static events = { binClicked: 'histogram-bin-clicked' }; constructor(d3Parent: D3Sel, eventHandler?: SimpleEventHandler, options: {} = {}) { super(d3Parent, eventHandler); this.superInitSVG(options, ['bg', 'main', 'box', 'fg']); this._init(); } protected _init() { const op = this.options; this.parent.attrs({ width: op.width, height: op.height, viewBox: `0 0 ${op.width} ${op.height}`, preserveAspectRatio: 'xMidYMid meet' }); this.layers.bg.append('g') .attr('class', 'y-axis') .attr('transform', `translate(${op.width - 33},0)`) this.layers.bg.append('g') .attr('class', 'y-axis-prob') // 背景面板:避免柱体与整体页面纯白背景混淆 this.layers.bg.insert('rect', ':first-child') .attr('class', 'panel-bg') .attr('x', -12) // 进一步向左扩展,形成更大留白 .attr('y', 0) .attr('width', op.width + 12) .attr('height', op.height) .attr('rx', 6) .attr('ry', 6) .style('fill', 'transparent'); this.layers.bg.append('g') .attr('class', 'x-axis') .attr('transform', `translate(0,${op.height - op.margin_bottom + 0.5})`) } protected _render(rD: HistogramData): void { const op = this.options; // extent 解析:'auto' 等价于 ['auto','auto'],支持双边独立配置 const [loSpec, hiSpec]: [HistogramExtentBound, HistogramExtentBound] = rD.extent === 'auto' ? ['auto', 'auto'] : rD.extent; const finite = rD.data.filter((d) => typeof d === 'number' && isFinite(d)); const [dataLo, dataHi] = finite.length > 0 ? (d3.extent(finite) as [number, number]) : [0, 1]; const fallbackLo = finite.length <= 1 ? dataLo - 0.5 : dataLo; const fallbackHi = finite.length <= 1 ? dataHi + 0.5 : dataHi; const lo = loSpec === 'auto' ? fallbackLo : loSpec; const hi = hiSpec === 'auto' ? fallbackHi : hiSpec; const extent: [number, number] = lo > hi ? [lo, lo] : [lo, hi]; // 计算bin宽度 const binWidth = (extent[1] - extent[0]) / rD.no_bins; // 超出上下界的按照对应bin的中心值处理 const values = rD.data.map(d => +d) .filter(d => isFinite(d)) .map(d => { if (d >= extent[1]) { // 超出或等于上界:映射到最后一个bin的中心值,避免d3.bin()为等于extent[1]的值创建额外的[19,19]bin return extent[1] - 0.5 * binWidth; } else if (d <= extent[0]) { // 超出或等于下界:映射到第一个bin的中心值,避免d3.bin()为等于extent[0]的值创建额外的[0,0]bin return extent[0] + 0.5 * binWidth; } return d; }); // 如果指定了 extent,确保使用 extent 作为 domain,而不是 nice() 调整后的 domain // 这样可以保证 extent 的上限被正确使用,即使数据被截断了 // 使用 extent 作为 domain,确保范围正确 const padding = { left: rD.showProbCurve ? 35 : 10, right: 35 }; let valueScale = d3.scaleLinear().domain([extent[0], extent[1]]).range([padding.left, op.width - padding.right]); const hasAverageValue = typeof rD.averageValue === 'number' && Number.isFinite(rD.averageValue); const clampedAverage = hasAverageValue ? Math.min(Math.max(rD.averageValue as number, extent[0]), extent[1]) : null; const averageX = hasAverageValue && clampedAverage !== null ? valueScale(clampedAverage) : null; const hasP90Value = typeof rD.p90Value === 'number' && Number.isFinite(rD.p90Value); const clampedP90 = hasP90Value ? Math.min(Math.max(rD.p90Value as number, extent[0]), extent[1]) : null; const p90X = hasP90Value && clampedP90 !== null ? valueScale(clampedP90) : null; // 统一的阈值生成逻辑:生成有限数阈值,两侧bin自动包含超出范围的值 // no_bins 是必选参数,直接使用等宽bin // thresholds 长度应该是 no_bins - 1,从 extent[0]+binWidth 开始,比如[0,10],10个bin,则thresholds长度为9,分别是1..9,不包括0和10 const thresholds = Array.from({ length: rD.no_bins - 1 }, (_, i) => extent[0] + (i + 1) * binWidth); // 设置domain确保边界严格按照extent划分,而不是实际的数据最大值和最小值 const histo = d3.bin() .domain(<[number, number]>[extent[0], extent[1]]) .thresholds(thresholds)(values); // 安全检查:确保 histo 不为空且 maxCount 有效 let maxCount = histo.length > 0 ? d3.max(histo, h => h.length) : 0; if (!isFinite(maxCount) || maxCount === null || maxCount === undefined) { console.warn('Invalid maxCount for histogram:', maxCount); maxCount = 1; } if (rD.fitExpectedCounts && rD.fitExpectedCounts.length > 0) { const fitMax = d3.max(rD.fitExpectedCounts) ?? 0; if (isFinite(fitMax) && fitMax > maxCount) maxCount = fitMax; } const useSqrt = rD.yScaleType === 'sqrt'; const useLog = rD.yScaleType === 'log'; const countScale = useLog ? d3.scaleSymlog().domain([0, Math.max(1, maxCount)]).range([op.height - op.margin_bottom, op.margin_top]) : useSqrt ? d3.scaleSqrt().domain([0, maxCount]).range([op.height - op.margin_bottom, op.margin_top]) : d3.scaleLinear().domain([0, maxCount]).nice().range([op.height - op.margin_bottom, op.margin_top]); // 与 d3 scaleBand 一致:bandwidth = step * (1 - paddingInner),gap = step * paddingInner // no_bins=20 时 barWidth:gap ≈ 2.875:1 → paddingInner ≈ 0.258 const PADDING_INNER = 0.15; const adjustWidth = (step: number) => { if (!isFinite(step) || step <= 0) return 0; return step * (1 - PADDING_INNER); }; const getBandWidth = (d: d3.Bin) => valueScale(d.x1) - valueScale(d.x0); const getBarCenterX = (d: d3.Bin) => { const x0 = valueScale(d.x0); const x1 = valueScale(d.x1); const width = adjustWidth(x1 - x0); const center = (isFinite(x0) ? x0 : 0) + 0.5 * (isFinite(width) ? width : 1); return isFinite(center) ? center : 0; }; const bars = this.layers.main.selectAll('.bar').data(histo) .join('rect') .attr('class', 'bar') .attrs({ x: d => { const bandWidth = getBandWidth(d); const barWidth = adjustWidth(bandWidth); const base = valueScale(d.x0); const offset = 0.5 * (bandWidth - barWidth); const x = base + offset; return isFinite(x) ? x : 0; }, y: d => { const y = countScale(d.length); return isFinite(y) ? y : op.height - op.margin_bottom; }, width: d => { const w = adjustWidth(getBandWidth(d)); return isFinite(w) && w > 0 ? w : 1; }, height: d => { if (d.length === 0) return 0; const h = op.height - op.margin_bottom - countScale(d.length); return isFinite(h) && h > 0 ? h : 1; }, }) .style('fill', d => { // 统一使用bin的中间值计算颜色 const colorValue = (d.x0 + d.x1) / 2; return rD.colorScale(colorValue); }) .style('stroke', (d, i) => { // 如果这个bin被选中,添加蓝色边框 return this._current.selectedBinIndex === i ? '#2a9eff' : 'none'; }) .style('stroke-width', (d, i) => { return this._current.selectedBinIndex === i ? '3' : '0'; }) .style('filter', (d, i) => { // 为选中的bin添加发光效果 return this._current.selectedBinIndex === i ? 'drop-shadow(0 0 6px rgba(42, 158, 255, 0.8))' : 'none'; }); // 拟合分布横虚线:每个 bin 上标识期望计数,宽度与柱体对齐 const fitData = rD.fitExpectedCounts && rD.fitExpectedCounts.length === histo.length ? histo.map((d, i) => { const bandWidth = getBandWidth(d); const barWidth = adjustWidth(bandWidth); const base = valueScale(d.x0); const x1 = base + 0.5 * (bandWidth - barWidth); return { x1, x2: x1 + barWidth, y: countScale(Math.max(0, rD.fitExpectedCounts![i])) }; }) : []; this.layers.main.selectAll('.fit-overlay-line').data(fitData) .join('line') .attr('class', 'fit-overlay-line') .attrs({ x1: d => d.x1, x2: d => d.x2, y1: d => d.y, y2: d => d.y, }) .style('stroke', 'var(--fit-line-color, #999)') .style('stroke-width', 1) .style('stroke-dasharray', '1,1'); const avgMarkerData = averageX !== null && Number.isFinite(averageX) ? [{ x: averageX, value: rD.averageValue as number }] : []; this.layers.fg.selectAll('.avg-line').data(avgMarkerData) .join('line') .attr('class', 'avg-line') .attrs({ x1: d => d.x, x2: d => d.x, y1: op.margin_top + 4, y2: op.height - op.margin_bottom }) .style('stroke', 'var(--avg-line-color, #8c8c8c)') .style('stroke-width', 1.5) .style('stroke-dasharray', '4,3'); this.layers.fg.selectAll('.avg-marker-label').data(avgMarkerData) .join('text') .attr('class', 'avg-marker-label sizeLabel') .attr('text-anchor', 'middle') .attr('x', d => d.x) .attr('y', op.margin_top) .text('avg'); const avgLabelData = (typeof rD.averageValue === 'number' && Number.isFinite(rD.averageValue)) ? [rD.averageValue] : []; this.layers.fg.selectAll('.avg-label').data(avgLabelData) .join('text') .attr('class', 'avg-label sizeLabel') .attr('text-anchor', 'end') .attr('x', op.width * 0.75) .attr('y', Math.max(12, op.margin_top - 2)) .text(value => { const suffix = rD.averageLabel ? ` ${rD.averageLabel}` : ''; return `avg = ${averageNumberFormat(value)}${suffix}`; }); const p90MarkerData = p90X !== null && Number.isFinite(p90X) ? [{ x: p90X, value: rD.p90Value as number }] : []; this.layers.fg.selectAll('.p90-line').data(p90MarkerData) .join('line') .attr('class', 'p90-line') .attrs({ x1: d => d.x, x2: d => d.x, y1: op.margin_top + 4, y2: op.height - op.margin_bottom }) .style('stroke', 'var(--p90-line-color, #8c8c8c)') .style('stroke-width', 1.5) .style('stroke-dasharray', '4,3'); this.layers.fg.selectAll('.p90-marker-label').data(p90MarkerData) .join('text') .attr('class', 'p90-marker-label sizeLabel') .attr('text-anchor', 'middle') .attr('x', d => d.x) .attr('y', op.margin_top) .text('p90'); const hasSignalThreshold = typeof rD.signalThreshold === 'number' && Number.isFinite(rD.signalThreshold); const clampedSignalThreshold = hasSignalThreshold ? Math.min(Math.max(rD.signalThreshold as number, extent[0]), extent[1]) : null; const signalThresholdX = hasSignalThreshold && clampedSignalThreshold !== null ? valueScale(clampedSignalThreshold) : null; const signalThresholdMarkerData = signalThresholdX !== null && Number.isFinite(signalThresholdX) ? [{ x: signalThresholdX, value: rD.signalThreshold as number, percentile: rD.signalThresholdPercentile }] : []; this.layers.fg.selectAll('.signal-threshold-line').data(signalThresholdMarkerData) .join('line') .attr('class', 'signal-threshold-line') .attrs({ x1: d => d.x, x2: d => d.x, y1: op.margin_top + 4, y2: op.height - op.margin_bottom }) .style('stroke', 'var(--signal-threshold-line-color, #e74c3c)') .style('stroke-width', 1.5) .style('stroke-dasharray', '3,2'); this.layers.fg.selectAll('.signal-threshold-marker-label').data(signalThresholdMarkerData) .join('text') .attr('class', 'signal-threshold-marker-label sizeLabel') .attr('text-anchor', 'middle') .attr('x', d => d.x) .attr('y', op.margin_top) .text(d => typeof d.percentile === 'number' ? `τ = p${d.percentile}` : 'τ'); const p90LabelData = (typeof rD.p90Value === 'number' && Number.isFinite(rD.p90Value)) ? [rD.p90Value] : []; const p90LabelY = avgLabelData.length > 0 ? Math.max(24, op.margin_top + 10) : Math.max(12, op.margin_top - 2); this.layers.fg.selectAll('.p90-label').data(p90LabelData) .join('text') .attr('class', 'p90-label sizeLabel') .attr('text-anchor', 'end') .attr('x', op.width * 0.75) .attr('y', p90LabelY) .text(value => { const suffix = rD.p90Label ? ` ${rD.p90Label}` : ''; return `p90 = ${averageNumberFormat(value)}${suffix}`; }); const labelData = histo.filter(bin => bin.length > 0); const labelAngle = -24; // 向左上倾斜 const totalCount = values.length; // 总数据点数量 // 格式化百分比:大于1%时2位有效数字,否则1位有效数字 const formatPercentage = (count: number, total: number): string => { const percentage = (count / total * 100); if (percentage > 1) { // > 1%: 2位有效数字,如 25%, 2.5% const formatted = Number(percentage.toPrecision(2)); return `${formatted}%`; } else { // <= 1%: 1位有效数字,如 0.2%, 0.02% const formatted = Number(percentage.toPrecision(1)); return `${formatted}%`; } }; this.layers.fg.selectAll('.bar-label').data(labelData) .join('text') .attr('class', 'bar-label sizeLabel') .attr('text-anchor', 'middle') .attr('transform', d => { const x = getBarCenterX(d); const y = countScale(d.length) - 4; const safeY = isFinite(y) ? y : op.margin_top; return `translate(${x},${safeY}) rotate(${labelAngle})`; }) .text(d => { // 获取当前bin的索引 const binIndex = histo.findIndex(bin => bin.x0 === d.x0 && bin.x1 === d.x1); // 如果这个bin被选中,显示数量,否则显示百分比 if (this._current.selectedBinIndex === binIndex) { return d.length; } else { return formatPercentage(d.length, totalCount); } }) .style('cursor', 'pointer'); // 添加更大的透明悬停区域 const hoverAreas = this.layers.main.selectAll('.hover-area').data(histo) .join('rect') .attr('class', 'hover-area') .attrs({ x: d => { const x = valueScale(d.x0); return isFinite(x) ? x : 0; }, y: op.margin_top, // 从顶部开始 width: d => { const w = adjustWidth(valueScale(d.x1) - valueScale(d.x0)); return isFinite(w) && w > 0 ? w : 1; }, height: op.height - op.margin_bottom - op.margin_top, // 覆盖整个图表高度 }) .style('fill', 'transparent') // 透明 .style('pointer-events', 'all') // 确保能接收鼠标事件 .style('cursor', 'pointer') // 添加指针光标 .on('mouseenter', (event, d) => { // 鼠标悬浮时,更新对应的label显示数量 const binIndex = histo.findIndex(bin => bin.x0 === d.x0 && bin.x1 === d.x1); this.layers.fg.selectAll('.bar-label') .filter((labelD: any, i: number) => { const labelBinIndex = histo.findIndex(bin => bin.x0 === labelD.x0 && bin.x1 === labelD.x1); return labelBinIndex === binIndex; }) .text(d.length); }) .on('mouseleave', (event, d) => { // 鼠标离开时,如果该bin未被选中,恢复显示百分比 const binIndex = histo.findIndex(bin => bin.x0 === d.x0 && bin.x1 === d.x1); if (this._current.selectedBinIndex !== binIndex) { this.layers.fg.selectAll('.bar-label') .filter((labelD: any, i: number) => { const labelBinIndex = histo.findIndex(bin => bin.x0 === labelD.x0 && bin.x1 === labelD.x1); return labelBinIndex === binIndex; }) .text((labelD: any) => formatPercentage(labelD.length, totalCount)); } }) .on('click', (event, d) => { // 阻止事件冒泡,避免触发body的点击事件清除高亮 event.stopPropagation(); // 使用数据绑定的索引,更可靠 const binIndex = histo.findIndex(bin => bin.x0 === d.x0 && bin.x1 === d.x1); // Toggle模式:如果点击已选中的bin,则取消选中 if (this._current.selectedBinIndex === binIndex) { this._current.selectedBinIndex = null; // 重新渲染以清除高亮效果 this._render(this.renderData); // 获取parent的id作为source标识 const sourceId = this.parent.attr('id') || this.parent.node()?.id || ''; // 触发点击事件,传递null表示取消选中 this.eventHandler.trigger(Histogram.events.binClicked, { binIndex: -1, // -1表示取消选中 x0: d.x0, x1: d.x1, data: d, no_bins: rD.no_bins, source: sourceId }); } else { // 更新选中的bin索引 this._current.selectedBinIndex = binIndex >= 0 ? binIndex : null; // 重新渲染以显示高亮效果 this._render(this.renderData); // 获取parent的id作为source标识 const sourceId = this.parent.attr('id') || this.parent.node()?.id || ''; // 触发点击事件 this.eventHandler.trigger(Histogram.events.binClicked, { binIndex: binIndex >= 0 ? binIndex : 0, x0: d.x0, x1: d.x1, data: d, no_bins: rD.no_bins, source: sourceId }); } }); const yAxis = d3.axisRight(countScale) .tickFormat(useLog ? d3.format('.0f') : op.numberFormat); if (useSqrt || useLog) yAxis.tickValues(getNonLinearTickValues(maxCount, 10)); this.layers.bg.select('.y-axis').call(yAxis); const tickValues = [extent[0], ...thresholds, extent[1]]; const tickSkip = rD.xAxisTickSkip ?? 0; // Custom tick format: 根据 showLeftInfinity/showRightInfinity 决定是否显示 ±∞ // xAxisTickSkip:减少刻度标签密度;xAxisTickRound:true 时按 step 对齐过滤,false 时按索引跳过 const xAxisTickFormat = (d: number) => { if (rD.showLeftInfinity && Math.abs(d - extent[0]) < 0.001) return '-∞'; if (rD.showRightInfinity && Math.abs(d - extent[1]) < 0.001) return '∞'; if (tickSkip > 0) { if (rD.xAxisTickRound) { const step = (tickSkip + 1) * binWidth; if (Math.abs(d / step - Math.round(d / step)) > 1e-9) return ''; } else { const idx = tickValues.findIndex((t) => Math.abs(t - d) < 1e-9 * (Math.abs(d) + 1)); if (idx >= 0 && idx % (tickSkip + 1) !== 0) return ''; } } return op.numberFormat(d); }; const xAxis = d3.axisBottom(valueScale) .tickFormat(xAxisTickFormat) .tickValues(tickValues); this.layers.bg.select('.x-axis').call(xAxis); const hasProbCurve = rD.showProbCurve && rD.probCurveData && rD.probCurveData.x.length > 0; if (hasProbCurve) { const probYScale = d3.scaleLinear() .domain([0, 1]) .range([op.height - op.margin_bottom, op.margin_top]); const probPoints: { x: number; y: number }[] = rD.probCurveData!.x.map((x, i) => ({ x, y: rD.probCurveData!.y[i] ?? 0 })); const probLine = d3.line<{ x: number; y: number }>() .x(d => valueScale(d.x)) .y(d => probYScale(d.y)) .curve(d3.curveLinear); this.layers.fg.selectAll('.prob-curve').data([probPoints]) .join('path') .attr('class', 'prob-curve') .attr('d', probLine) .style('fill', 'none') .style('stroke', 'var(--prob-curve-color, rgba(160,200,255,0.85))') .style('stroke-width', 1.5) .style('pointer-events', 'none'); const probAxis = d3.axisLeft(probYScale) .ticks(5) .tickFormat(d3.format('.1f')); this.layers.bg.select('.y-axis-prob') .attr('transform', `translate(${padding.left},0)`) .call(probAxis); this.layers.bg.selectAll('.prob-curve-axis-label').data([1]) .join('text') .attr('class', 'prob-curve-axis-label sizeLabel') .attr('text-anchor', 'middle') .attr('transform', `translate(8,${(op.height - op.margin_bottom) / 2 + op.margin_top}) rotate(-90)`) .text(tr('signal ratio')); } else { this.layers.fg.selectAll('.prob-curve').remove(); this.layers.bg.select('.y-axis-prob').selectAll('*').remove(); this.layers.bg.selectAll('.prob-curve-axis-label').remove(); } } protected _wrangle(data) { return data; } /** * 设置选中的bin索引 * @param binIndex bin索引,如果为null则清除选中状态 */ setSelectedBin(binIndex: number | null) { this._current.selectedBinIndex = binIndex; // 重新渲染以显示高亮效果 if (this.renderData) { this._render(this.renderData); } } /** * 清除选中状态 */ clearSelection() { this.setSelectedBin(null); } }