158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
import { ts } from '../../i18n/server';
|
|
import { getEnumValueColor } from '../../lib/consts';
|
|
|
|
function shortenAxisLabel(label: string, total: number): string {
|
|
if (label.length <= 3) return label;
|
|
const parts = label.split(/[\s/&-]+/).filter(Boolean);
|
|
if (parts.length > 1) {
|
|
return parts
|
|
.map((part) => Array.from(part)[0])
|
|
.join('')
|
|
.slice(0, 3);
|
|
}
|
|
return Array.from(label)
|
|
.slice(0, total <= 5 ? 3 : 2)
|
|
.join('');
|
|
}
|
|
|
|
export default function EnumBarChart({
|
|
counts,
|
|
globalCounts,
|
|
featureName,
|
|
compact = false,
|
|
}: {
|
|
counts: Record<string, number>;
|
|
globalCounts?: Record<string, number>;
|
|
featureName: string;
|
|
compact?: boolean;
|
|
}) {
|
|
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
|
if (entries.length === 0) return null;
|
|
|
|
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
|
|
|
// When global counts are available, normalize both to percentages for comparison
|
|
const globalTotal = globalCounts ? Object.values(globalCounts).reduce((sum, c) => sum + c, 0) : 0;
|
|
|
|
const hasGlobal = globalCounts && globalTotal > 0;
|
|
|
|
// Compute max percentage across both datasets for consistent bar scaling
|
|
const maxPct = entries.reduce((max, [label, count]) => {
|
|
const localPct = localTotal > 0 ? count / localTotal : 0;
|
|
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
|
|
return Math.max(max, localPct, globalPct);
|
|
}, 0);
|
|
|
|
// Fallback to raw count scaling when no global data
|
|
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
|
|
|
|
if (compact) {
|
|
const title = entries
|
|
.map(([label, count]) => {
|
|
const localPct = localTotal > 0 ? (count / localTotal) * 100 : 0;
|
|
const globalPct =
|
|
hasGlobal && globalTotal > 0 ? ((globalCounts[label] ?? 0) / globalTotal) * 100 : null;
|
|
return `${ts(label)}: ${count.toLocaleString()} (${localPct.toFixed(1)}%)${
|
|
globalPct != null ? ` / ${globalPct.toFixed(1)}%` : ''
|
|
}`;
|
|
})
|
|
.join('\n');
|
|
|
|
return (
|
|
<div className="h-10" title={title}>
|
|
<div className="flex h-7 items-end gap-[2px]">
|
|
{entries.map(([label, count]) => {
|
|
const localPct = localTotal > 0 ? count / localTotal : 0;
|
|
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
|
|
const localHeight = hasGlobal
|
|
? maxPct > 0
|
|
? (localPct / maxPct) * 100
|
|
: 0
|
|
: (count / maxCount) * 100;
|
|
const globalHeight = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
|
|
const color = getEnumValueColor(featureName, label);
|
|
|
|
return (
|
|
<div key={label} className="relative flex h-full min-w-[3px] flex-1 items-end">
|
|
{hasGlobal && (
|
|
<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, globalPct > 0 ? 8 : 0)}%` }}
|
|
/>
|
|
)}
|
|
{count > 0 && (
|
|
<div
|
|
className="absolute bottom-0 left-[18%] right-[18%] rounded-t-[2px]"
|
|
style={{
|
|
height: `${Math.max(localHeight, 12)}%`,
|
|
backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})`,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="mt-0.5 flex gap-[2px]">
|
|
{entries.map(([label]) => {
|
|
const translated = ts(label);
|
|
return (
|
|
<span
|
|
key={label}
|
|
className="min-w-[3px] flex-1 truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
|
|
title={translated}
|
|
>
|
|
{shortenAxisLabel(translated, entries.length)}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1 mt-1">
|
|
{entries.map(([label, count]) => {
|
|
const localPct = localTotal > 0 ? count / localTotal : 0;
|
|
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
|
|
|
|
const localWidth = hasGlobal
|
|
? maxPct > 0
|
|
? (localPct / maxPct) * 100
|
|
: 0
|
|
: (count / maxCount) * 100;
|
|
const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
|
|
|
|
const color = getEnumValueColor(featureName, label);
|
|
const barStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
|
|
|
|
return (
|
|
<div key={label} className="flex items-center gap-2 text-xs">
|
|
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
|
{ts(label)}
|
|
</span>
|
|
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden relative">
|
|
{hasGlobal && (
|
|
<div
|
|
className="absolute inset-y-0 left-0 bg-warm-300/60 dark:bg-warm-600/60 rounded"
|
|
style={{ width: `${globalWidth}%` }}
|
|
/>
|
|
)}
|
|
<div
|
|
className="h-full rounded relative"
|
|
style={{
|
|
width: `${localWidth}%`,
|
|
backgroundColor: barStyle,
|
|
}}
|
|
/>
|
|
</div>
|
|
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">
|
|
{count}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|