290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
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<number, string>(); // 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 (
|
|
<div className={showCompactAxisLabels ? 'h-10' : 'h-7'} title={chartTitle}>
|
|
<div
|
|
className={`${showCompactAxisLabels ? 'h-7' : 'h-full'} relative flex items-end gap-[2px]`}
|
|
>
|
|
{Array.from({ length: barCount }).map((_, index) => {
|
|
const globalHeight = (globalBars[index] / globalMax) * 100;
|
|
const localHeight = (localBars[index] / localMax) * 100;
|
|
return (
|
|
<div key={index} className="relative flex h-full min-w-[2px] flex-1 items-end">
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 rounded-t-[2px] bg-warm-300/45 dark:bg-warm-600/50"
|
|
style={{ height: `${Math.max(globalHeight, globalBars[index] > 0 ? 8 : 0)}%` }}
|
|
/>
|
|
{localBars[index] > 0 && (
|
|
<div
|
|
className="absolute bottom-0 left-[22%] right-[22%] rounded-t-[2px] bg-teal-600 dark:bg-teal-400"
|
|
style={{ height: `${Math.max(localHeight, 12)}%` }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{showCompactAxisLabels && (
|
|
<div className="mt-0.5 flex gap-[2px]">
|
|
{axisLabels.map((label, index) => (
|
|
<span
|
|
key={`${label}-${index}`}
|
|
className="min-w-[2px] flex-1 truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
|
|
title={label}
|
|
>
|
|
{label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mt-1">
|
|
<div className={showMeanMarker ? 'relative pt-5' : 'relative'}>
|
|
<div className="relative flex items-end gap-px h-10">
|
|
{Array.from({ length: barCount }).map((_, index) => {
|
|
const globalHeight = (globalBars[index] / globalMax) * 100;
|
|
const localHeight = (localBars[index] / localMax) * 100;
|
|
return (
|
|
<div key={index} className="flex-1 relative min-w-[2px] h-full flex items-end">
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 bg-warm-300/40 dark:bg-warm-600/40 rounded-t-sm"
|
|
style={{ height: `${globalHeight}%` }}
|
|
/>
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 bg-teal-500 dark:bg-teal-400 rounded-t-sm"
|
|
style={{
|
|
height: `${localHeight}%`,
|
|
opacity: localBars[index] > 0 ? 1 : 0.1,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{showMeanMarker && (
|
|
<div className="pointer-events-none absolute inset-y-0" style={{ left: `${meanPct}%` }}>
|
|
<div
|
|
className="absolute top-0 max-w-[7rem] truncate rounded-sm border border-warm-300 bg-white px-1 py-0.5 text-[9px] font-medium leading-none text-warm-600 shadow-sm dark:border-warm-600 dark:bg-navy-900 dark:text-warm-300"
|
|
style={meanLabelStyle}
|
|
>
|
|
{meanLabel ?? t('areaPane.nationalAvg')}
|
|
</div>
|
|
<div className="absolute bottom-0 top-5 w-px border-l border-dashed border-warm-400 dark:border-warm-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{tickBars.size > 0 && (
|
|
<div className="flex gap-px mt-0.5">
|
|
{Array.from({ length: barCount }).map((_, index) => (
|
|
<div key={index} className="flex-1 min-w-[2px] text-center">
|
|
{tickBars.has(index) && (
|
|
<span className="text-[9px] leading-none text-warm-400 dark:text-warm-500">
|
|
{tickBars.get(index)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SkeletonHistogram() {
|
|
return (
|
|
<div className="grid min-h-10 animate-pulse grid-cols-[minmax(0,1fr)_6.5rem_minmax(3.5rem,auto)] items-center gap-3 py-1.5">
|
|
<div className="h-3 w-24 rounded bg-warm-200 dark:bg-warm-700" />
|
|
<div className="flex h-7 items-end gap-[2px]">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="min-w-[2px] flex-1 rounded-t-[2px] bg-warm-200 dark:bg-warm-700"
|
|
style={{ height: `${22 + Math.sin(i * 0.7) * 28 + 30}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="h-3 w-10 justify-self-end rounded bg-warm-200 dark:bg-warm-700" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function LoadingSkeleton() {
|
|
return (
|
|
<div className="space-y-4 p-3">
|
|
{[0, 1, 2].map((groupIdx) => (
|
|
<div key={groupIdx}>
|
|
<div className="mb-2 h-3 w-20 animate-pulse rounded bg-warm-200 dark:bg-warm-700" />
|
|
<div className="divide-y divide-warm-100 dark:divide-navy-800">
|
|
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
|
|
<SkeletonHistogram key={i} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|