function downsampleBars(counts: number[], targetBars: number): number[] { const step = Math.max(1, Math.floor(counts.length / targetBars)); const bars: number[] = []; for (let index = 0; index < counts.length; index += step) { let sum = 0; for (let offset = 0; offset < step && index + offset < counts.length; offset++) { sum += counts[index + offset]; } bars.push(sum); } return bars; } function pickTicks(min: number, max: number, count: number): number[] { if (max <= min) return [min]; const range = max - min; const rawStep = range / (count - 1); const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); const nice = [1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= rawStep) ?? 10; const step = nice * magnitude; const start = Math.ceil(min / step) * step; const ticks: number[] = []; for (let v = start; v <= max + step * 0.01; v += step) { ticks.push(v); } // Ensure at least min and max are represented if (ticks.length === 0) return [min, max]; return ticks; } export function DualHistogram({ localCounts, globalCounts, p1, p99, globalMean, formatLabel, }: { localCounts: number[]; globalCounts: number[]; p1: number; p99: number; globalMean?: number; formatLabel?: (value: number) => string; }) { const targetBars = 25; const localBars = downsampleBars(localCounts, targetBars); const globalBars = downsampleBars(globalCounts, targetBars); const barCount = Math.min(localBars.length, globalBars.length); const localMax = Math.max(...localBars, 1); const globalMax = Math.max(...globalBars, 1); const fmt = formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1))); // Compute center value for each bar. // Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier. const middleBins = Math.max(barCount - 2, 0); const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0; const barCenters: number[] = Array.from({ length: barCount }, (_, i) => { if (i === 0) return p1; // outlier bin, label as p1 if (i === barCount - 1) return p99; // outlier bin, label as p99 return p1 + (i - 1 + 0.5) * middleWidth; }); // Pick nice tick values and assign each to the nearest bar const ticks = p99 > p1 ? pickTicks(p1, p99, 6) : []; const tickBars = new Map(); // bar index → label for (const v of ticks) { let bestBar = 1; let bestDist = Infinity; for (let i = 1; i < barCount - 1; i++) { const dist = Math.abs(barCenters[i] - v); if (dist < bestDist) { bestDist = dist; bestBar = i; } } if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v)); } // Mean line: position as fraction across the bar area const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null; // Account for outlier bins: middle region spans bars 1..n-2 const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null; return (
{Array.from({ length: barCount }).map((_, index) => { const globalHeight = (globalBars[index] / globalMax) * 100; const localHeight = (localBars[index] / localMax) * 100; return (
0 ? 1 : 0.1, }} />
); })} {meanPct != null && meanPct >= 0 && meanPct <= 100 && (
)}
{tickBars.size > 0 && (
{Array.from({ length: barCount }).map((_, index) => (
{tickBars.has(index) && ( {tickBars.get(index)} )}
))}
)}
); } function SkeletonHistogram() { return (
{Array.from({ length: 15 }).map((_, i) => (
))}
); } export function LoadingSkeleton() { return (
{[0, 1, 2].map((groupIdx) => (
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => ( ))}
))}
); }