import { useTranslation } from 'react-i18next'; 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; } function isLowCardinalityHistogram(counts: number[], p1: number, p99: number): boolean { return counts.length > 0 && counts.length <= 10 && p99 > p1 && p99 - p1 <= 10; } export function compactHistogramLabel( index: number, barCount: number, p1: number, p99: number, center: number, formatLabel: (value: number) => string, integerLabels = false ): string { const formatAxisValue = (value: number) => integerLabels ? Math.round(value).toLocaleString() : formatLabel(value); if (barCount <= 1) return formatAxisValue(center); const middleBins = barCount - 2; if (index === 0) { if (!integerLabels) return `<${formatLabel(p1)}`; const firstBoundary = Math.ceil(p1); // This outlier bin holds values strictly below p1. When p1 <= 0 there are no // (non-negative) integers below it, so the value 0 lives in the first middle // bin instead. Labelling this empty bin "0" too would show "0" twice. if (firstBoundary <= 0) return ''; return firstBoundary === 1 ? '0' : `<${firstBoundary.toLocaleString()}`; } if (index === barCount - 1) { if (!integerLabels) return `${formatLabel(p99)}+`; return `${Math.ceil(p99).toLocaleString()}+`; } const middleWidth = middleBins > 0 ? (p99 - p1) / middleBins : 0; if (Math.abs(middleWidth - 1) < 0.001) { return formatAxisValue(p1 + index - 1); } return formatAxisValue(center); } export function DualHistogram({ localCounts, globalCounts, p1, p99, globalMean, meanLabel, formatLabel, compact = false, integerAxisLabels = false, }: { localCounts: number[]; globalCounts: number[]; p1: number; p99: number; globalMean?: number; meanLabel?: string; formatLabel?: (value: number) => string; compact?: boolean; integerAxisLabels?: boolean; }) { const { t } = useTranslation(); const showCompactAxisLabels = compact && isLowCardinalityHistogram(localCounts, p1, p99) && isLowCardinalityHistogram(globalCounts, p1, p99); const targetBars = compact ? (showCompactAxisLabels ? localCounts.length : 16) : 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))); if (barCount === 0) return null; // 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; const showMeanMarker = meanPct != null && meanPct >= 0 && meanPct <= 100; const meanLabelStyle = showMeanMarker && meanPct < 12 ? { left: 0 } : showMeanMarker && meanPct > 88 ? { right: 0 } : { left: '50%', transform: 'translateX(-50%)' }; if (compact) { const axisLabels = showCompactAxisLabels ? barCenters.map((center, index) => compactHistogramLabel(index, barCount, p1, p99, center, fmt, integerAxisLabels) ) : []; const chartTitle = [ `${fmt(p1)} - ${fmt(p99)}`, globalMean != null ? `${meanLabel ?? t('areaPane.nationalAvg')}: ${fmt(globalMean)}` : null, ] .filter(Boolean) .join('\n'); return (
{Array.from({ length: barCount }).map((_, index) => { const globalHeight = (globalBars[index] / globalMax) * 100; const localHeight = (localBars[index] / localMax) * 100; return (
0 ? 8 : 0)}%` }} /> {localBars[index] > 0 && (
)}
); })}
{showCompactAxisLabels && (
{axisLabels.map((label, index) => ( {label} ))}
)}
); } return (
{Array.from({ length: barCount }).map((_, index) => { const globalHeight = (globalBars[index] / globalMax) * 100; const localHeight = (localBars[index] / localMax) * 100; return (
0 ? 1 : 0.1, }} />
); })}
{showMeanMarker && (
{meanLabel ?? t('areaPane.nationalAvg')}
)}
{tickBars.size > 0 && (
{Array.from({ length: barCount }).map((_, index) => (
{tickBars.has(index) && ( {tickBars.get(index)} )}
))}
)}
); } function SkeletonHistogram() { return (
{Array.from({ length: 12 }).map((_, i) => (
))}
); } export function LoadingSkeleton() { return (
{[0, 1, 2].map((groupIdx) => (
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => ( ))}
))}
); }