mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
199 lines
7.3 KiB
TypeScript
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;
|