| 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'); |
|
|
| |
| 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); |
| } |
|
|
| |
| export type HistogramExtentBound = number | 'auto'; |
|
|
| |
| export type HistogramExtent = [HistogramExtentBound, HistogramExtentBound] | 'auto'; |
|
|
| export type HistogramData = { |
| data: number[], |
| label?: string, |
| no_bins: number, |
| extent: HistogramExtent, |
| colorScale: (value: number) => string, |
| averageValue?: number, |
| p90Value?: number, |
| averageLabel?: string, |
| p90Label?: string, |
| showLeftInfinity?: boolean, |
| showRightInfinity?: boolean, |
| |
| xAxisTickSkip?: number, |
| |
| xAxisTickRound?: boolean; |
| yScaleType?: 'linear' | 'sqrt' | 'log' |
| |
| fitExpectedCounts?: number[]; |
| |
| showProbCurve?: boolean; |
| |
| probCurveData?: { x: number[]; y: number[] }; |
| |
| signalThreshold?: number | null; |
| |
| signalThresholdPercentile?: number | null; |
| } |
|
|
|
|
| export type HistogramBinClickEvent = { |
| binIndex: number; |
| x0: number; |
| x1: number; |
| data: number[]; |
| no_bins: number; |
| source?: string; |
| } |
|
|
| export class Histogram extends VComponent<HistogramData> { |
| protected _current = { |
| selectedBinIndex: null as number | null |
| }; |
| 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; |
|
|
| |
| 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]; |
|
|
| |
| const binWidth = (extent[1] - extent[0]) / rD.no_bins; |
|
|
| |
| const values = rD.data.map(d => +d) |
| .filter(d => isFinite(d)) |
| .map(d => { |
| if (d >= extent[1]) { |
| |
| return extent[1] - 0.5 * binWidth; |
| } else if (d <= extent[0]) { |
| |
| return extent[0] + 0.5 * binWidth; |
| } |
| return d; |
| }); |
|
|
| |
| |
| |
| 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; |
|
|
| |
| |
| |
| const thresholds = Array.from({ length: rD.no_bins - 1 }, (_, i) => extent[0] + (i + 1) * binWidth); |
|
|
| |
| const histo = d3.bin() |
| .domain(<[number, number]>[extent[0], extent[1]]) |
| .thresholds(thresholds)(values); |
|
|
| |
| 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]); |
|
|
| |
| |
| 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<number, number>) => valueScale(d.x1) - valueScale(d.x0); |
| const getBarCenterX = (d: d3.Bin<number, number>) => { |
| 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 => { |
| |
| const colorValue = (d.x0 + d.x1) / 2; |
| return rD.colorScale(colorValue); |
| }) |
| .style('stroke', (d, i) => { |
| |
| return this._current.selectedBinIndex === i ? '#2a9eff' : 'none'; |
| }) |
| .style('stroke-width', (d, i) => { |
| return this._current.selectedBinIndex === i ? '3' : '0'; |
| }) |
| .style('filter', (d, i) => { |
| |
| return this._current.selectedBinIndex === i ? 'drop-shadow(0 0 6px rgba(42, 158, 255, 0.8))' : 'none'; |
| }); |
|
|
| |
| 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; |
| |
| |
| const formatPercentage = (count: number, total: number): string => { |
| const percentage = (count / total * 100); |
| if (percentage > 1) { |
| |
| const formatted = Number(percentage.toPrecision(2)); |
| return `${formatted}%`; |
| } else { |
| |
| 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 => { |
| |
| const binIndex = histo.findIndex(bin => bin.x0 === d.x0 && bin.x1 === d.x1); |
| |
| 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) => { |
| |
| 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) => { |
| |
| 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) => { |
| |
| event.stopPropagation(); |
| |
| |
| const binIndex = histo.findIndex(bin => bin.x0 === d.x0 && bin.x1 === d.x1); |
| |
| |
| if (this._current.selectedBinIndex === binIndex) { |
| this._current.selectedBinIndex = null; |
| |
| this._render(this.renderData); |
| |
| const sourceId = this.parent.attr('id') || this.parent.node()?.id || ''; |
| |
| this.eventHandler.trigger(Histogram.events.binClicked, <HistogramBinClickEvent>{ |
| binIndex: -1, |
| x0: d.x0, |
| x1: d.x1, |
| data: d, |
| no_bins: rD.no_bins, |
| source: sourceId |
| }); |
| } else { |
| |
| this._current.selectedBinIndex = binIndex >= 0 ? binIndex : null; |
| |
| this._render(this.renderData); |
| |
| const sourceId = this.parent.attr('id') || this.parent.node()?.id || ''; |
| |
| this.eventHandler.trigger(Histogram.events.binClicked, <HistogramBinClickEvent>{ |
| 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(<any>yAxis); |
| |
| const tickValues = [extent[0], ...thresholds, extent[1]]; |
| const tickSkip = rD.xAxisTickSkip ?? 0; |
| |
| |
| |
| 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(<any>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(<any>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; |
| } |
|
|
| |
| |
| |
| |
| setSelectedBin(binIndex: number | null) { |
| this._current.selectedBinIndex = binIndex; |
| |
| if (this.renderData) { |
| this._render(this.renderData); |
| } |
| } |
|
|
| |
| |
| |
| clearSelection() { |
| this.setSelectedBin(null); |
| } |
|
|
| } |