"use client";
import { Btn } from "@/components/btn";
import {
Bar,
BarChart,
Cell,
ErrorBar,
LabelList,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { ClusterRow } from "@/lib/types";
import { useTheme } from "@/lib/providers";
import { useState } from "react";
import { A, B, FF } from "@/lib/theme";
function Card({ children, title, subtitle }: { children: React.ReactNode; title: string; subtitle: string }) {
const { t } = useTheme();
return (
{title}
{subtitle}
{children}
);
}
export function ForestPlot({ clusters }: { clusters: ClusterRow[] }) {
const { t } = useTheme();
const [selected, setSelected] = useState(null);
const [hovered, setHovered] = useState(null);
const rows = [...clusters]
.filter((c) => c.mean_delta !== null)
.sort((a, b) => (a.mean_delta ?? 0) - (b.mean_delta ?? 0))
.map((c) => {
const x = (c.mean_delta ?? 0) * 100;
const lo = (c.ci_low ?? 0) * 100;
const hi = (c.ci_high ?? 0) * 100;
const color = c.winner === "A" ? A : c.winner === "B" ? B : t.faint;
return { label: c.label, x, err: [x - lo, hi - x] as [number, number], color, row: c };
});
return (
{selected ? (
setSelected(null)} t={t} />
) : (
<>
v.toFixed(0)}
stroke={t.border}
/>
[`${v.toFixed(2)} pts`, "Δ (B − A)"]}
/>
setSelected(d?.payload?.row ?? null)}
onMouseEnter={(_: any, i: number) => setHovered(i)}
onMouseLeave={() => setHovered(null)}
>
{
const { x, y, width, height, index, viewBox } = props;
if (index !== hovered) return null;
const label = "(click to see details)";
const px = 6.2 * label.length; // approx text width at 10.5px mono
const barEnd = x + width; // right end for +bars, left end for -bars
const rightEdge = (viewBox?.x ?? 0) + (viewBox?.width ?? 9999);
const leftEdge = viewBox?.x ?? 0;
let tx: number;
let anchor: "start" | "end";
let inside = false;
if (width >= 0) {
if (barEnd + 8 + px <= rightEdge) { tx = barEnd + 8; anchor = "start"; }
else { tx = barEnd - 8; anchor = "end"; inside = true; }
} else {
if (barEnd - 8 - px >= leftEdge) { tx = barEnd - 8; anchor = "end"; }
else { tx = barEnd + 8; anchor = "start"; inside = true; }
}
const wide = Math.abs(width) > px + 20;
return (
{label}
);
}}
/>
{rows.map((r, i) => (
|
))}
{"\u25C0"} Résumé A matches better
Résumé B matches better {"\u25B6"}
>
)}
);
}
export function Chips({
items,
color,
t,
empty = "no distinguishing skills in this segment",
}: {
items: { skill: string; freq: number }[];
color: string;
t: any;
empty?: string;
}) {
if (!items.length)
return (
{empty}
);
const max = Math.max(...items.map((i) => i.freq), 0.01);
return (
{items.map((it, idx) => (
{/* frequency bar — structure encodes the % */}
{it.skill}
{Math.round(it.freq * 100)}%
))}
);
}
function DiffPanel({ row, onBack, t }: { row: ClusterRow; onBack: () => void; t: any }) {
const d = row.differentiators ?? { a_favoring: [], b_favoring: [] };
return (
{"\u2190"} back to all clusters
{row.label}
Skills common in this cluster's postings that appear in only one résumé — what separates
A from B here. (The score itself is semantic similarity, so read these as differentiators, not exact causes.)
{"\u25C0"} FAVORS RÉSUMÉ A
FAVORS RÉSUMÉ B {"\u25B6"}
);
}