189 lines
6.8 KiB
TypeScript
189 lines
6.8 KiB
TypeScript
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,
|
|
meanLabel = 'National avg',
|
|
formatLabel,
|
|
}: {
|
|
localCounts: number[];
|
|
globalCounts: number[];
|
|
p1: number;
|
|
p99: number;
|
|
globalMean?: number;
|
|
meanLabel?: string;
|
|
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<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%)' };
|
|
|
|
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}
|
|
</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="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
|
|
<div className="flex justify-between items-baseline">
|
|
<div className="h-3 w-24 bg-warm-200 dark:bg-warm-700 rounded" />
|
|
<div className="h-3 w-10 bg-warm-200 dark:bg-warm-700 rounded" />
|
|
</div>
|
|
<div className="flex items-end gap-px h-10 mt-2">
|
|
{Array.from({ length: 15 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-1 bg-warm-200 dark:bg-warm-700 rounded-t-sm min-w-[2px]"
|
|
style={{ height: `${20 + Math.sin(i * 0.7) * 30 + 30}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-between mt-1">
|
|
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
|
|
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function LoadingSkeleton() {
|
|
return (
|
|
<div className="p-3 space-y-4">
|
|
{[0, 1, 2].map((groupIdx) => (
|
|
<div key={groupIdx}>
|
|
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
|
|
<div className="space-y-3">
|
|
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
|
|
<SkeletonHistogram key={i} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|