Files
openhuman/app/src/components/intelligence/GraphCohesionPanel.tsx
T
2026-05-30 10:14:15 -07:00

199 lines
7.3 KiB
TypeScript

/**
* Graph Cohesion — presentational view. Pure: renders the cohesion summary
* tiles (entities / connections / triangles), the network averages, and a
* brokerage ranking of the loosest-neighbourhood entities. No data fetching,
* no clock, no randomness.
*/
import { useMemo } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { type CohesionResult, findBrokers } from '../../lib/memory/graphCohesion';
const MAX_ROWS = 25;
interface GraphCohesionPanelProps {
result: CohesionResult | null;
loading?: boolean;
error?: string | null;
onRetry?: () => void;
}
const GraphCohesionPanel = ({ result, loading, error, onRetry }: GraphCohesionPanelProps) => {
const { t } = useT();
const brokers = useMemo(() => (result ? findBrokers(result, MAX_ROWS) : []), [result]);
const intro = (
<div
role="note"
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('graphCohesion.title')}</p>
<p>{t('graphCohesion.intro')}</p>
</div>
);
if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('graphCohesion.loading')}
data-testid="graph-cohesion-loading">
<div className="grid gap-2 sm:grid-cols-3">
{[0, 1, 2].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-16"
/>
))}
</div>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-8"
/>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-4">
{intro}
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 p-4 text-center">
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
{t('graphCohesion.errorPrefix')} {error}
</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600">
{t('graphCohesion.retry')}
</button>
)}
</div>
</div>
);
}
if (!result || result.nodes.length === 0) {
return (
<div className="space-y-4">
{intro}
<div className="py-8 text-center">
<h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('graphCohesion.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('graphCohesion.emptyHint')}
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{intro}
{/* Metric tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('graphCohesion.metricEntities'), value: result.nodeCount },
{ label: t('graphCohesion.metricConnections'), value: result.edgeCount },
{ label: t('graphCohesion.metricTriangles'), value: result.triangleCount },
].map(tile => (
<div
key={tile.label}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3">
<div className="text-[10px] uppercase tracking-wider text-stone-400 dark:text-neutral-500">
{tile.label}
</div>
<div className="text-lg font-semibold tabular-nums text-stone-900 dark:text-neutral-100">
{tile.value}
</div>
</div>
))}
</div>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{t('graphCohesion.summaryCaption')
.replace('{avg}', result.averageClustering.toFixed(2))
.replace('{transitivity}', result.transitivity.toFixed(2))}
</p>
{/* Brokerage ranking: loosest neighbourhoods first (structural holes). */}
{brokers.length === 0 ? (
<p className="py-4 text-center text-xs text-stone-500 dark:text-neutral-400">
{t('graphCohesion.noBrokers')}
</p>
) : (
<section aria-labelledby="graph-cohesion-heading" className="space-y-1">
<h3
id="graph-cohesion-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('graphCohesion.rankedHeading')}
</h3>
<table className="w-full text-left text-[11px] tabular-nums">
<thead className="text-stone-400 dark:text-neutral-500">
<tr>
<th scope="col" className="w-8 py-1 pr-2 font-medium">
{t('graphCohesion.colRank')}
</th>
<th scope="col" className="py-1 pr-2 font-medium">
{t('graphCohesion.colEntity')}
</th>
<th scope="col" className="w-1/3 py-1 pr-2 font-medium">
{t('graphCohesion.colCohesion')}
</th>
<th scope="col" className="w-12 py-1 text-right font-medium">
{t('graphCohesion.colLinks')}
</th>
</tr>
</thead>
<tbody>
{brokers.map((node, i) => (
<tr key={node.id} className="border-t border-stone-100 dark:border-neutral-800/60">
<td className="py-1 pr-2 text-stone-400 dark:text-neutral-500">{i + 1}</td>
<td className="py-1 pr-2 text-stone-800 dark:text-neutral-100 break-words">
{node.id}
{node.localClustering === 0 && (
<span
title={t('graphCohesion.brokerTitle')}
className="ml-1.5 inline-flex items-center rounded px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wider bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300">
{t('graphCohesion.brokerBadge')}
</span>
)}
</td>
<td className="py-1 pr-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full bg-primary-400/70"
style={{ width: `${node.localClustering * 100}%` }}
/>
</div>
<span className="w-10 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{node.localClustering.toFixed(2)}
</span>
</div>
</td>
<td className="py-1 text-right text-stone-500 dark:text-neutral-400">
{node.degree}
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</div>
);
};
export default GraphCohesionPanel;