feat(graph): dynamic force-directed Team-memory graph + honest metrics (on main's pipeline)
Lands the dynamic Team-memory redesign on top of main's continual-learning pipeline. main already computes loop/activity in the API but its GraphView never rendered them and kept a static-column graph; this swaps in the dynamic graph and surfaces the rails, reusing main's richer PodLearningLoop / PodGraphActivity types. Frontend (new frontend/src/components/graph/*, composed into GraphView.tsx): - forceSim.ts: dependency-free force layout (charge, link springs, centroid recenter, 2-pass collision, bounds clamp, alpha anneal) — no new deps/lockfile churn. - GraphCanvas.tsx: SVG render from the sim — draggable + pinnable nodes, curved edges, weight-sized shapes, fade-in, animated learned_from dash, risk-path lighting / rest dimmed, label collision-avoidance. - MetricsRail / LearningLoop / ActivityStream / SelectedNodePanel / encoding.ts: the mock's rails + stream + detail panel in light shadcn. LearningLoop consumes main's PodLearningLoop (steps + activeStep); ActivityStream consumes PodGraphActivity (title + detail). SelectedNodePanel adds a Flow section that narrates the path through a clicked node (flowNarrative); default copy is mode-aware. Edge legend rounded out (editing/touches). - GraphView polls every 5s and diffs (positions preserved), + ws /api/events nudge. Stale selection (node gone across a poll) dropped so the canvas can't dim entirely. Backend (surgical — main's materializer + buildLoop kept): - live.ts: headline metric cards derived from the FINAL de-noised graph (Open risk paths = distinct collision files; Learned owners = distinct owner engineers) instead of raw collision-signature / accepted-outcome counts that inflate with test churn (50 -> 4 risk files, 16 -> 1 owner on live). buildLoop untouched. - demo.ts: metrics realigned to the demo graph (3 owners / 1 risk path / 100%). Verified: lint + -r typecheck + -r build pass; Playwright confirmed the dynamic graph (ticks on load, draggable), the loop rail (5 steps, ADAPT active) and activity stream rendering main's shapes, honest metrics (3/1/100%), and the flow narrative per node kind — zero page errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,21 +11,23 @@ export function createDemoPodGraph(podId: string): PodGraph {
|
|||||||
return {
|
return {
|
||||||
podId,
|
podId,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
|
// Kept consistent with the graph below (3 owner engineers, 1 collision file,
|
||||||
|
// 1 of 1 interventions accepted) so the numbers never contradict the picture.
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
label: 'Learned owners',
|
label: 'Learned owners',
|
||||||
value: '5',
|
value: '3',
|
||||||
detail: 'Ownership edges retained from accepted interventions.',
|
detail: 'Distinct owners retained from accepted interventions.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Open risk paths',
|
label: 'Open risk paths',
|
||||||
value: '2',
|
value: '1',
|
||||||
detail: 'auth.ts and the memory API have converging editors.',
|
detail: 'File with two or more converging editors.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Accept rate',
|
label: 'Accept rate',
|
||||||
value: '86%',
|
value: '100%',
|
||||||
detail: 'Interventions accepted this session (+14%).',
|
detail: 'Interventions accepted vs total this session.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
loop: {
|
loop: {
|
||||||
|
|||||||
@@ -502,6 +502,7 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
|
|
||||||
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
||||||
const totalOutcomes = outcomeDocs.length;
|
const totalOutcomes = outcomeDocs.length;
|
||||||
|
// Raw distinct collision signatures — kept for the learning-loop throughput view.
|
||||||
const riskPaths = new Set(
|
const riskPaths = new Set(
|
||||||
collisionDocs.map(
|
collisionDocs.map(
|
||||||
(col) =>
|
(col) =>
|
||||||
@@ -509,21 +510,38 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
|||||||
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
|
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
|
||||||
),
|
),
|
||||||
).size;
|
).size;
|
||||||
|
|
||||||
|
// Headline metric cards are derived from the FINAL de-noised graph so they match
|
||||||
|
// what's drawn. Counting raw collision signatures / accepted-outcome rows inflates
|
||||||
|
// them with test churn (e.g. 50 "risk paths" for 4 files), which reads as fake.
|
||||||
|
const finalEdges = [...b.edges.values()];
|
||||||
|
const riskFiles = new Set<string>();
|
||||||
|
for (const e of finalEdges) {
|
||||||
|
if (e.kind === 'touches' && b.nodes.get(e.source)?.kind === 'file') riskFiles.add(e.source);
|
||||||
|
}
|
||||||
|
const openRiskPaths = riskFiles.size || nodes.filter((n) => n.kind === 'collision').length;
|
||||||
|
const ownerSet = new Set<string>();
|
||||||
|
for (const e of finalEdges) {
|
||||||
|
if (e.kind === 'learned_from') ownerSet.add(e.target);
|
||||||
|
if (e.kind === 'owns') ownerSet.add(e.source);
|
||||||
|
}
|
||||||
|
const learnedOwners = [...ownerSet].filter((id) => b.nodes.get(id)?.kind === 'engineer').length;
|
||||||
|
|
||||||
const metrics: PodGraphMetric[] = [
|
const metrics: PodGraphMetric[] = [
|
||||||
{
|
{
|
||||||
label: 'Learned owners',
|
label: 'Learned owners',
|
||||||
value: String(acceptedReal),
|
value: String(learnedOwners),
|
||||||
detail: 'Ownership retained from accepted interventions.',
|
detail: 'Distinct owners retained from accepted interventions.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Open risk paths',
|
label: 'Open risk paths',
|
||||||
value: String(riskPaths),
|
value: String(openRiskPaths),
|
||||||
detail: 'Files with two or more converging editors.',
|
detail: `${openRiskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Accept rate',
|
label: 'Accept rate',
|
||||||
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
|
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
|
||||||
detail: 'Interventions accepted this session.',
|
detail: 'Interventions accepted vs total this session.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const learnedEdges = [...b.edges.values()].filter((e) => e.kind === 'learned_from').length;
|
const learnedEdges = [...b.edges.values()].filter((e) => e.kind === 'learned_from').length;
|
||||||
|
|||||||
@@ -1,111 +1,36 @@
|
|||||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
import type { PodGraph } from '@podman/shared';
|
||||||
import { fetchPodGraph } from '../lib/graph.js';
|
import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import { GraphCanvas } from './graph/GraphCanvas.js';
|
||||||
|
import { MetricsRail } from './graph/MetricsRail.js';
|
||||||
|
import { LearningLoop } from './graph/LearningLoop.js';
|
||||||
|
import { ActivityStream } from './graph/ActivityStream.js';
|
||||||
|
import { SelectedNodePanel } from './graph/SelectedNodePanel.js';
|
||||||
|
import { highlightFor, flowNarrative, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js';
|
||||||
|
|
||||||
type Mode = 'risk' | 'learn' | 'all';
|
const POLL_MS = 5000;
|
||||||
|
|
||||||
// Fixed, light-readable hues for the node/edge encoding (kept stable across
|
// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
|
||||||
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
|
// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
|
||||||
const BLUE = '#2563eb';
|
const GRAPH_CSS = `
|
||||||
const SLATE = '#475569';
|
.pm-node{cursor:grab;transition:opacity .25s ease}
|
||||||
const SLATE_EDGE = '#94a3b8';
|
.pm-node:active{cursor:grabbing}
|
||||||
const SLATE_FAINT = '#cbd5e1';
|
.pm-edge{transition:opacity .25s ease}
|
||||||
const AMBER = '#d97706';
|
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none;
|
||||||
const RED = '#dc2626';
|
paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round}
|
||||||
const VIOLET = '#7c3aed';
|
.pm-dim{opacity:.14}
|
||||||
|
.pm-enter{animation:pm-fade .45s ease}
|
||||||
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
.pm-dash{animation:pm-flow 1s linear infinite}
|
||||||
engineer: BLUE,
|
.pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
|
||||||
file: SLATE,
|
@keyframes pm-fade{from{opacity:0}to{opacity:1}}
|
||||||
feature: AMBER,
|
@keyframes pm-flow{to{stroke-dashoffset:-26}}
|
||||||
collision: RED,
|
@keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
|
||||||
intervention: VIOLET,
|
@media (prefers-reduced-motion:reduce){
|
||||||
};
|
.pm-enter,.pm-dash,.pm-pulse{animation:none}
|
||||||
|
|
||||||
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
|
||||||
owns: { c: BLUE, w: 2.6 },
|
|
||||||
editing: { c: SLATE_EDGE, w: 2 },
|
|
||||||
touches: { c: SLATE_FAINT, w: 1.6 },
|
|
||||||
collides: { c: RED, w: 3.2 },
|
|
||||||
warns: { c: AMBER, w: 3.2 },
|
|
||||||
learned_from: { c: VIOLET, w: 2.4, dash: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
function NodeShape({ node }: { node: PodGraphNode }) {
|
|
||||||
const c = KIND_COLOR[node.kind];
|
|
||||||
const { x, y } = node;
|
|
||||||
switch (node.kind) {
|
|
||||||
case 'engineer':
|
|
||||||
return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />;
|
|
||||||
case 'file':
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
x={x - 15}
|
|
||||||
y={y - 15}
|
|
||||||
width={30}
|
|
||||||
height={30}
|
|
||||||
rx={4}
|
|
||||||
fill="none"
|
|
||||||
stroke={c}
|
|
||||||
strokeWidth={2.6}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'feature':
|
|
||||||
return <circle cx={x} cy={y} r={17} fill={c} />;
|
|
||||||
case 'collision':
|
|
||||||
return <polygon points={`${x},${y - 18} ${x + 17},${y + 13} ${x - 17},${y + 13}`} fill={c} />;
|
|
||||||
case 'intervention':
|
|
||||||
return (
|
|
||||||
<polygon points={`${x},${y - 18} ${x + 18},${y} ${x},${y + 18} ${x - 18},${y}`} fill={c} />
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
`;
|
||||||
|
|
||||||
interface Highlight {
|
|
||||||
nodes: Set<string>;
|
|
||||||
edges: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
|
||||||
if (selected) {
|
|
||||||
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
|
||||||
return {
|
|
||||||
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
|
||||||
edges: new Set(es.map((e) => e.id)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (mode === 'all') return null;
|
|
||||||
const kinds: PodGraphEdge['kind'][] =
|
|
||||||
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
|
||||||
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
|
||||||
const es = graph.edges.filter(
|
|
||||||
(e) =>
|
|
||||||
kinds.includes(e.kind) ||
|
|
||||||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
|
||||||
edges: new Set(es.map((e) => e.id)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
|
||||||
{ label: 'engineer', swatch: { background: BLUE } },
|
|
||||||
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
|
|
||||||
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
|
|
||||||
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
|
|
||||||
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
|
|
||||||
];
|
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
|
||||||
if (status === 'risk') return RED;
|
|
||||||
if (status === 'learned') return VIOLET;
|
|
||||||
return 'var(--foreground)';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
||||||
const [graph, setGraph] = useState<PodGraph | null>(null);
|
const [graph, setGraph] = useState<PodGraph | null>(null);
|
||||||
@@ -115,198 +40,170 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
let nudge: number | null = null;
|
||||||
setGraph(null);
|
setGraph(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchPodGraph(podId)
|
setSelected(null);
|
||||||
.then((g) => alive && setGraph(g))
|
|
||||||
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
|
const load = () =>
|
||||||
|
fetchPodGraph(podId)
|
||||||
|
.then((g) => {
|
||||||
|
if (alive) {
|
||||||
|
setGraph(g);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
void load();
|
||||||
|
const poll = window.setInterval(() => void load(), POLL_MS);
|
||||||
|
|
||||||
|
// Best-effort realtime nudge: refetch (debounced) when the agent broadcasts.
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(backendEventsUrl());
|
||||||
|
ws.onmessage = () => {
|
||||||
|
if (nudge != null) return;
|
||||||
|
nudge = window.setTimeout(() => {
|
||||||
|
nudge = null;
|
||||||
|
void load();
|
||||||
|
}, 800);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
/* event bus is optional */
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
|
window.clearInterval(poll);
|
||||||
|
if (nudge != null) window.clearTimeout(nudge);
|
||||||
|
ws?.close();
|
||||||
};
|
};
|
||||||
}, [podId]);
|
}, [podId]);
|
||||||
|
|
||||||
const hi = useMemo(
|
|
||||||
() => (graph ? highlightFor(graph, mode, selected) : null),
|
|
||||||
[graph, mode, selected],
|
|
||||||
);
|
|
||||||
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
|
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
|
||||||
const sel = selected ? nodeById.get(selected) : undefined;
|
// A selected node can vanish across a poll/WS refresh. Ignore a stale id so the
|
||||||
const relCount = selected
|
// graph doesn't dim entirely (highlightFor would otherwise light only a dead id).
|
||||||
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
|
const liveSelected = selected && nodeById.has(selected) ? selected : null;
|
||||||
: 0;
|
useEffect(() => {
|
||||||
|
if (selected && graph && !nodeById.has(selected)) setSelected(null);
|
||||||
|
}, [graph, nodeById, selected]);
|
||||||
|
|
||||||
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
|
const highlight = useMemo(
|
||||||
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
|
() => (graph ? highlightFor(graph, mode, liveSelected) : null),
|
||||||
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
|
[graph, mode, liveSelected],
|
||||||
|
);
|
||||||
|
const sel = liveSelected ? nodeById.get(liveSelected) : undefined;
|
||||||
|
const relCount = liveSelected
|
||||||
|
? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected)
|
||||||
|
.length
|
||||||
|
: 0;
|
||||||
|
const flow = graph && liveSelected ? flowNarrative(graph, liveSelected) : '';
|
||||||
|
|
||||||
function pick(next: Mode) {
|
function pick(next: Mode) {
|
||||||
setMode(next);
|
setMode(next);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<style>{GRAPH_CSS}</style>
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||||
<style>{`
|
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
|
||||||
.pm-node{cursor:pointer}
|
{/* Header */}
|
||||||
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
|
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
|
||||||
.pm-dim{opacity:.18;transition:opacity .25s}
|
<div className="min-w-0">
|
||||||
`}</style>
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="font-heading text-base font-medium">Team memory</h2>
|
||||||
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
|
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<div className="flex items-center justify-between border-b px-5 py-4">
|
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
|
||||||
<div>
|
Live
|
||||||
<h2 className="text-base font-medium">Team memory</h2>
|
</span>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
|
What PodMan learned · {podId}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
← Pods
|
← Pods
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
|
{/* Toggles */}
|
||||||
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
|
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
|
||||||
Risk path
|
<ToggleGroup
|
||||||
</Button>
|
type="single"
|
||||||
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
|
value={mode}
|
||||||
Learning edges
|
onValueChange={(v) => v && pick(v as Mode)}
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
|
size="sm"
|
||||||
Whole graph
|
>
|
||||||
</Button>
|
<ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
|
||||||
|
Drag to rearrange · double-click to release · click to inspect
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
|
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
|
||||||
{!graph && !error && (
|
{!graph && !error && (
|
||||||
<p className="px-4 py-4 text-sm text-muted-foreground">Loading graph…</p>
|
<p className="px-4 py-10 text-center text-sm text-muted-foreground">Loading graph…</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{graph && (
|
{graph && (
|
||||||
<>
|
<>
|
||||||
<div className="grid lg:grid-cols-[190px_1fr_250px]">
|
{/* Metrics · graph · learning loop */}
|
||||||
<div className="space-y-3 p-4">
|
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<MetricsRail metrics={graph.metrics} />
|
||||||
Workflow metrics
|
|
||||||
</p>
|
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
|
||||||
{graph.metrics.map((m) => (
|
<GraphCanvas
|
||||||
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
|
graph={graph}
|
||||||
<p className="text-2xl font-medium tabular-nums">{m.value}</p>
|
highlight={highlight}
|
||||||
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
|
selected={liveSelected}
|
||||||
{m.label}
|
onSelect={setSelected}
|
||||||
</p>
|
/>
|
||||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
|
{graph.loop?.steps?.length ? <LearningLoop loop={graph.loop} /> : <div />}
|
||||||
<svg
|
</div>
|
||||||
viewBox="0 0 720 472"
|
|
||||||
role="img"
|
|
||||||
aria-label="PodMan team-memory graph"
|
|
||||||
className="block h-auto w-full"
|
|
||||||
>
|
|
||||||
{graph.edges.map((e) => {
|
|
||||||
const a = nodeById.get(e.source);
|
|
||||||
const b = nodeById.get(e.target);
|
|
||||||
if (!a || !b) return null;
|
|
||||||
const s = EDGE[e.kind];
|
|
||||||
return (
|
|
||||||
<line
|
|
||||||
key={e.id}
|
|
||||||
className={dimEdge(e.id) ? 'pm-dim' : undefined}
|
|
||||||
x1={a.x}
|
|
||||||
y1={a.y}
|
|
||||||
x2={b.x}
|
|
||||||
y2={b.y}
|
|
||||||
stroke={s.c}
|
|
||||||
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
|
|
||||||
strokeDasharray={s.dash ? '7 6' : undefined}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{graph.nodes.map((n) => (
|
|
||||||
<g
|
|
||||||
key={n.id}
|
|
||||||
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={`${n.kind}: ${n.label}`}
|
|
||||||
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
||||||
ev.preventDefault();
|
|
||||||
setSelected((cur) => (cur === n.id ? null : n.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NodeShape node={n} />
|
|
||||||
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
|
|
||||||
{n.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
|
{/* Activity stream · selected node */}
|
||||||
{sel ? (
|
<div className="grid gap-4 border-t px-4 py-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<>
|
<div className="rounded-xl border bg-card p-4">
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
<ActivityStream events={graph.activity ?? []} />
|
||||||
{sel.kind}
|
</div>
|
||||||
</p>
|
<div className="rounded-xl border bg-muted/40 p-4">
|
||||||
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
|
<SelectedNodePanel node={sel} relCount={relCount} flow={flow} mode={mode} />
|
||||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
|
||||||
<span>Status</span>
|
|
||||||
<Badge variant="outline" style={{ color: statusColor(sel.status) }}>
|
|
||||||
{sel.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
|
||||||
<span>Relationships</span>
|
|
||||||
<span className="font-medium text-foreground">{relCount}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{sel.summary}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
Continual learning
|
|
||||||
</p>
|
|
||||||
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
The violet{' '}
|
|
||||||
<span className="font-medium" style={{ color: VIOLET }}>
|
|
||||||
learned_from
|
|
||||||
</span>{' '}
|
|
||||||
edges are ownership PodMan retained from accepted interventions — the graph
|
|
||||||
gets sharper every session. Click any node to trace its relationships.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
{/* Legend */}
|
||||||
{LEGEND.map((l) => (
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||||
|
{NODE_LEGEND.map((l) => (
|
||||||
<span key={l.label} className="flex items-center gap-1.5">
|
<span key={l.label} className="flex items-center gap-1.5">
|
||||||
<span className="inline-block size-3" style={l.swatch} />
|
<span className="inline-block size-3" style={l.swatch} />
|
||||||
{l.label}
|
{l.label}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
|
||||||
<span className="inline-block h-[3px] w-3" style={{ background: RED }} />
|
{EDGE_LEGEND.map((l) => (
|
||||||
collides
|
<span key={l.label} className="flex items-center gap-1.5">
|
||||||
</span>
|
<span
|
||||||
<span className="flex items-center gap-1.5">
|
className="inline-block h-[3px] w-3.5"
|
||||||
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
|
style={
|
||||||
learned_from
|
l.dash
|
||||||
</span>
|
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
|
||||||
|
: { background: l.color }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{l.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { PodGraphActivity } from '@podman/shared';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { ACTIVITY_TAG } from './encoding.js';
|
||||||
|
|
||||||
|
const fmtTime = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
|
||||||
|
function timeOf(at: string): string {
|
||||||
|
const t = new Date(at).getTime();
|
||||||
|
return Number.isFinite(t) ? fmtTime.format(t) : '--:--';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityStream({ events }: { events: PodGraphActivity[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Activity stream
|
||||||
|
</p>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[176px] pr-3">
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{events.map((e) => {
|
||||||
|
const tag = ACTIVITY_TAG[e.kind];
|
||||||
|
return (
|
||||||
|
<li key={e.id} className="pm-enter flex items-start gap-2.5 text-sm">
|
||||||
|
<span className="mt-0.5 shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{timeOf(e.at)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide"
|
||||||
|
style={{ color: tag.color, background: `${tag.color}1a` }}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 leading-snug">
|
||||||
|
<span className="text-foreground/90">{e.title}</span>
|
||||||
|
{e.detail && (
|
||||||
|
<span className="block text-xs leading-snug text-muted-foreground">
|
||||||
|
{e.detail}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactElement,
|
||||||
|
type PointerEvent,
|
||||||
|
} from 'react';
|
||||||
|
import type { PodGraph, PodGraphNode, PodGraphNodeKind } from '@podman/shared';
|
||||||
|
import { ForceSim } from './forceSim.js';
|
||||||
|
import { EDGE, KIND_COLOR, nodeRadius, type Highlight } from './encoding.js';
|
||||||
|
|
||||||
|
const W = 760;
|
||||||
|
const H = 480;
|
||||||
|
const MARGIN = 48;
|
||||||
|
|
||||||
|
/** Map the server's 0..720×0..472 layout into the canvas as a seed position. */
|
||||||
|
function mapX(x: number): number {
|
||||||
|
return MARGIN + (Math.max(0, Math.min(720, x)) / 720) * (W - 2 * MARGIN);
|
||||||
|
}
|
||||||
|
function mapY(y: number): number {
|
||||||
|
return MARGIN + (Math.max(0, Math.min(472, y)) / 472) * (H - 2 * MARGIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkDistance(kind: string): number {
|
||||||
|
if (kind === 'collides') return 122;
|
||||||
|
if (kind === 'owns') return 104;
|
||||||
|
if (kind === 'learned_from') return 150;
|
||||||
|
return 134;
|
||||||
|
}
|
||||||
|
function linkStrength(strength: number): number {
|
||||||
|
return Math.max(0.18, Math.min(0.9, strength));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable +/- so parallel edges between the same pair fan to opposite sides. */
|
||||||
|
function curveSign(id: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) h = (h + id.charCodeAt(i)) % 2;
|
||||||
|
return h === 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgePath(ax: number, ay: number, bx: number, by: number, id: string): string {
|
||||||
|
const dx = bx - ax;
|
||||||
|
const dy = by - ay;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const nx = -dy / len;
|
||||||
|
const ny = dx / len;
|
||||||
|
const off = curveSign(id) * len * 0.13;
|
||||||
|
const cx = (ax + bx) / 2 + nx * off;
|
||||||
|
const cy = (ay + by) / 2 + ny * off;
|
||||||
|
return `M${ax.toFixed(1)},${ay.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${bx.toFixed(1)},${by.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeShape(
|
||||||
|
kind: PodGraphNodeKind,
|
||||||
|
color: string,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
r: number,
|
||||||
|
): ReactElement | null {
|
||||||
|
switch (kind) {
|
||||||
|
case 'engineer':
|
||||||
|
return <rect x={cx - r} y={cy - r} width={r * 2} height={r * 2} rx={4} fill={color} />;
|
||||||
|
case 'file':
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
x={cx - r}
|
||||||
|
y={cy - r}
|
||||||
|
width={r * 2}
|
||||||
|
height={r * 2}
|
||||||
|
rx={4}
|
||||||
|
fill="var(--card)"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2.4}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'feature':
|
||||||
|
return <circle cx={cx} cy={cy} r={r} fill={color} />;
|
||||||
|
case 'collision':
|
||||||
|
return (
|
||||||
|
<polygon
|
||||||
|
points={`${cx},${cy - r} ${cx + r},${cy + r * 0.78} ${cx - r},${cy + r * 0.78}`}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'intervention':
|
||||||
|
return (
|
||||||
|
<polygon points={`${cx},${cy - r} ${cx + r},${cy} ${cx},${cy + r} ${cx - r},${cy}`} fill={color} />
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLabel(
|
||||||
|
node: PodGraphNode,
|
||||||
|
dimmed: boolean,
|
||||||
|
hovered: boolean,
|
||||||
|
selected: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (hovered || selected) return true;
|
||||||
|
if (dimmed) return false;
|
||||||
|
// Collisions cluster and often share a filename — reveal on hover/select only.
|
||||||
|
if (node.kind === 'collision') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
id: string;
|
||||||
|
pointerId: number;
|
||||||
|
moved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphCanvas({
|
||||||
|
graph,
|
||||||
|
highlight,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
graph: PodGraph;
|
||||||
|
highlight: Highlight | null;
|
||||||
|
selected: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const simRef = useRef<ForceSim | null>(null);
|
||||||
|
if (!simRef.current) simRef.current = new ForceSim(W, H);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
const sigRef = useRef<string>('');
|
||||||
|
const [, setFrame] = useState(0);
|
||||||
|
const [hovered, setHovered] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loop = useCallback(() => {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
const working = sim.tick();
|
||||||
|
setFrame((f) => (f + 1) % 1_000_000);
|
||||||
|
if (working || dragRef.current) {
|
||||||
|
rafRef.current = requestAnimationFrame(loop);
|
||||||
|
} else {
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ensureRaf = useCallback(() => {
|
||||||
|
if (rafRef.current == null) rafRef.current = requestAnimationFrame(loop);
|
||||||
|
}, [loop]);
|
||||||
|
|
||||||
|
// Rebuild the simulation when the graph data changes, preserving positions.
|
||||||
|
useEffect(() => {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
const nodeInputs = graph.nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
radius: nodeRadius(n),
|
||||||
|
seedX: mapX(n.x),
|
||||||
|
seedY: mapY(n.y),
|
||||||
|
}));
|
||||||
|
const linkInputs = graph.edges.map((e) => ({
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
distance: linkDistance(e.kind),
|
||||||
|
strength: linkStrength(e.strength),
|
||||||
|
}));
|
||||||
|
const sig =
|
||||||
|
nodeInputs
|
||||||
|
.map((n) => n.id)
|
||||||
|
.sort()
|
||||||
|
.join(',') +
|
||||||
|
'|' +
|
||||||
|
graph.edges
|
||||||
|
.map((e) => e.id)
|
||||||
|
.sort()
|
||||||
|
.join(',');
|
||||||
|
const first = sigRef.current === '';
|
||||||
|
const changed = sig !== sigRef.current;
|
||||||
|
sim.setData(nodeInputs, linkInputs);
|
||||||
|
if (changed) {
|
||||||
|
sigRef.current = sig;
|
||||||
|
sim.reheat(first ? 1 : 0.5);
|
||||||
|
}
|
||||||
|
// Always (re)arm the loop — ensureRaf is idempotent via the rafRef==null
|
||||||
|
// guard. This must NOT be gated on `changed`: under React StrictMode the
|
||||||
|
// dev double-invoke cancels the frame between effect passes, and pass 2 sees
|
||||||
|
// an unchanged sig, so a `changed`-gated start would leave the sim frozen.
|
||||||
|
if (sim.nodes.length) ensureRaf();
|
||||||
|
}, [graph, ensureRaf]);
|
||||||
|
|
||||||
|
// Clean up the animation frame on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toSvg(evt: PointerEvent): { x: number; y: number } {
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!svg) return { x: 0, y: 0 };
|
||||||
|
const ctm = svg.getScreenCTM();
|
||||||
|
if (!ctm) return { x: 0, y: 0 };
|
||||||
|
const p = new DOMPoint(evt.clientX, evt.clientY).matrixTransform(ctm.inverse());
|
||||||
|
return { x: p.x, y: p.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodePointerDown(evt: PointerEvent, id: string) {
|
||||||
|
evt.stopPropagation();
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
(evt.currentTarget as Element).setPointerCapture(evt.pointerId);
|
||||||
|
dragRef.current = { id, pointerId: evt.pointerId, moved: false };
|
||||||
|
const { x, y } = toSvg(evt);
|
||||||
|
sim.pin(id, x, y);
|
||||||
|
sim.setActive(true);
|
||||||
|
ensureRaf();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodePointerMove(evt: PointerEvent) {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!drag || !sim || drag.pointerId !== evt.pointerId) return;
|
||||||
|
drag.moved = true;
|
||||||
|
const { x, y } = toSvg(evt);
|
||||||
|
sim.pin(drag.id, x, y);
|
||||||
|
ensureRaf();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodePointerUp(evt: PointerEvent, id: string) {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!drag || !sim || drag.pointerId !== evt.pointerId) return;
|
||||||
|
(evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId);
|
||||||
|
sim.setActive(false);
|
||||||
|
// A press that never moved is a click — toggle selection (node stays pinned).
|
||||||
|
if (!drag.moved) onSelect(selected === id ? null : id);
|
||||||
|
dragRef.current = null;
|
||||||
|
ensureRaf();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeDoubleClick(id: string) {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
sim.unpin(id);
|
||||||
|
sim.reheat(0.5);
|
||||||
|
ensureRaf();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sim = simRef.current;
|
||||||
|
const dimNode = (id: string) => (highlight ? !highlight.nodes.has(id) : false);
|
||||||
|
const dimEdge = (id: string) => (highlight ? !highlight.edges.has(id) : false);
|
||||||
|
const hotEdge = (id: string) => (highlight ? highlight.edges.has(id) : false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
role="img"
|
||||||
|
aria-label="PodMan team-memory graph — drag nodes to rearrange"
|
||||||
|
className="block h-full max-h-[560px] w-full touch-none select-none"
|
||||||
|
onPointerDown={() => onSelect(null)}
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
{graph.edges.map((e) => {
|
||||||
|
const a = sim?.get(e.source);
|
||||||
|
const b = sim?.get(e.target);
|
||||||
|
if (!a || !b) return null;
|
||||||
|
const style = EDGE[e.kind];
|
||||||
|
const hot = hotEdge(e.id);
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={e.id}
|
||||||
|
className={`pm-edge pm-enter ${dimEdge(e.id) ? 'pm-dim' : ''} ${e.kind === 'learned_from' ? 'pm-dash' : ''}`}
|
||||||
|
d={edgePath(a.x, a.y, b.x, b.y, e.id)}
|
||||||
|
fill="none"
|
||||||
|
stroke={style.c}
|
||||||
|
strokeWidth={hot ? style.w + 1.4 : style.w}
|
||||||
|
strokeOpacity={hot ? 1 : 0.78}
|
||||||
|
strokeDasharray={style.dash ? '7 6' : undefined}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{graph.nodes.map((n) => {
|
||||||
|
const p = sim?.get(n.id);
|
||||||
|
if (!p) return null;
|
||||||
|
const r = p.radius;
|
||||||
|
const dimmed = dimNode(n.id);
|
||||||
|
const isHover = hovered === n.id;
|
||||||
|
const isSel = selected === n.id;
|
||||||
|
const color = KIND_COLOR[n.kind];
|
||||||
|
const pinned = p.fx != null;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={n.id}
|
||||||
|
className={`pm-node pm-enter ${dimmed ? 'pm-dim' : ''}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${n.kind}: ${n.label}`}
|
||||||
|
onPointerDown={(ev) => onNodePointerDown(ev, n.id)}
|
||||||
|
onPointerMove={onNodePointerMove}
|
||||||
|
onPointerUp={(ev) => onNodePointerUp(ev, n.id)}
|
||||||
|
onDoubleClick={() => onNodeDoubleClick(n.id)}
|
||||||
|
onMouseEnter={() => setHovered(n.id)}
|
||||||
|
onMouseLeave={() => setHovered((cur) => (cur === n.id ? null : cur))}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||||
|
ev.preventDefault();
|
||||||
|
onSelect(selected === n.id ? null : n.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(isSel || isHover) && (
|
||||||
|
<circle cx={p.x} cy={p.y} r={r + 7} fill="none" stroke={color} strokeWidth={2} strokeOpacity={0.5} />
|
||||||
|
)}
|
||||||
|
{pinned && !isSel && !isHover && (
|
||||||
|
<circle cx={p.x} cy={p.y} r={r + 4} fill="none" stroke={color} strokeWidth={1} strokeDasharray="2 3" strokeOpacity={0.4} />
|
||||||
|
)}
|
||||||
|
{nodeShape(n.kind, color, p.x, p.y, r)}
|
||||||
|
{showLabel(n, dimmed, isHover, isSel) && (
|
||||||
|
<text className="pm-lbl" x={p.x} y={p.y + r + 13} textAnchor="middle">
|
||||||
|
{n.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { PodLearningLoop } from '@podman/shared';
|
||||||
|
import { BLUE } from './encoding.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The continual-learning loop rail: observe → store → predict → outcome → adapt.
|
||||||
|
* The active stage (most-recent activity) gets a pulsing accent bar + ring.
|
||||||
|
*/
|
||||||
|
export function LearningLoop({ loop }: { loop: PodLearningLoop }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Learning loop
|
||||||
|
</p>
|
||||||
|
{loop.steps.map((s, i) => {
|
||||||
|
const active = s.status === 'active' || s.key === loop.activeStep;
|
||||||
|
return (
|
||||||
|
<div key={s.key}>
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-lg border bg-card py-2 pl-3.5 pr-3 shadow-sm transition-colors data-[active=true]:bg-accent/40"
|
||||||
|
data-active={active}
|
||||||
|
style={active ? { boxShadow: `inset 0 0 0 1px ${BLUE}55` } : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`absolute inset-y-0 left-0 w-1 ${active ? 'pm-pulse' : ''}`}
|
||||||
|
style={{ background: active ? BLUE : 'var(--border)' }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<p className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<span className="tabular-nums">{String(i + 1).padStart(2, '0')}</span> {s.label}
|
||||||
|
</p>
|
||||||
|
<p className="font-heading text-sm font-semibold tabular-nums">{s.value}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">{s.detail}</p>
|
||||||
|
</div>
|
||||||
|
{i < loop.steps.length - 1 && (
|
||||||
|
<p
|
||||||
|
aria-hidden
|
||||||
|
className="py-0.5 text-center text-xs leading-none text-muted-foreground/60"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { PodGraphMetric } from '@podman/shared';
|
||||||
|
import { BLUE, RED, VIOLET, GREEN, AMBER } from './encoding.js';
|
||||||
|
|
||||||
|
const ACCENTS: Array<{ test: RegExp; color: string }> = [
|
||||||
|
{ test: /risk|collision|open/i, color: RED },
|
||||||
|
{ test: /accept/i, color: GREEN },
|
||||||
|
{ test: /learn|owner|adapt/i, color: VIOLET },
|
||||||
|
{ test: /vector|memory|store/i, color: AMBER },
|
||||||
|
];
|
||||||
|
|
||||||
|
function accentFor(label: string, i: number): string {
|
||||||
|
for (const a of ACCENTS) if (a.test.test(label)) return a.color;
|
||||||
|
return [BLUE, RED, VIOLET, GREEN, AMBER][i % 5] ?? BLUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsRail({ metrics }: { metrics: PodGraphMetric[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Workflow metrics
|
||||||
|
</p>
|
||||||
|
{metrics.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={m.label}
|
||||||
|
className="rounded-lg border bg-card py-2.5 pl-3 pr-3 shadow-sm"
|
||||||
|
style={{ borderLeftWidth: 3, borderLeftColor: accentFor(m.label, i) }}
|
||||||
|
>
|
||||||
|
<p className="font-heading text-2xl font-semibold leading-none tabular-nums">{m.value}</p>
|
||||||
|
<p className="mt-1.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{m.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { PodGraphNode } from '@podman/shared';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { statusColor, modeBlurb, VIOLET, type Mode } from './encoding.js';
|
||||||
|
|
||||||
|
export function SelectedNodePanel({
|
||||||
|
node,
|
||||||
|
relCount,
|
||||||
|
flow,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
node: PodGraphNode | undefined;
|
||||||
|
relCount: number;
|
||||||
|
flow: string;
|
||||||
|
mode: Mode;
|
||||||
|
}) {
|
||||||
|
if (!node) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{mode === 'learn' ? 'Learning edges' : mode === 'all' ? 'Whole graph' : 'Risk path'}
|
||||||
|
</p>
|
||||||
|
<h3 className="mb-2 font-heading text-base font-medium">What you're looking at</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">{modeBlurb(mode)}</p>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Click any node to trace its{' '}
|
||||||
|
<span className="font-medium" style={{ color: VIOLET }}>
|
||||||
|
flow
|
||||||
|
</span>{' '}
|
||||||
|
— what PodMan saw, flagged, and learned. Drag to rearrange.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{node.kind}
|
||||||
|
</p>
|
||||||
|
<h3 className="mb-2 mt-0.5 font-heading text-lg font-medium">{node.label}</h3>
|
||||||
|
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||||
|
<span>Status</span>
|
||||||
|
<Badge variant="outline" style={{ color: statusColor(node.status) }}>
|
||||||
|
{node.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||||
|
<span>Relationships</span>
|
||||||
|
<span className="font-medium text-foreground">{relCount}</span>
|
||||||
|
</div>
|
||||||
|
{flow && (
|
||||||
|
<>
|
||||||
|
<p className="mt-2.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Flow
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{flow}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{node.summary && node.summary !== flow && (
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{node.summary}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import type {
|
||||||
|
PodGraph,
|
||||||
|
PodGraphNode,
|
||||||
|
PodGraphEdge,
|
||||||
|
PodGraphNodeKind,
|
||||||
|
PodGraphActivityKind,
|
||||||
|
} from '@podman/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed, light-readable hues for the node/edge encoding. Kept stable across
|
||||||
|
* light/dark so kinds stay distinguishable; only the chrome uses shadcn tokens.
|
||||||
|
*/
|
||||||
|
export const BLUE = '#2563eb';
|
||||||
|
export const SLATE = '#475569';
|
||||||
|
export const SLATE_EDGE = '#94a3b8';
|
||||||
|
export const SLATE_FAINT = '#cbd5e1';
|
||||||
|
export const AMBER = '#d97706';
|
||||||
|
export const RED = '#dc2626';
|
||||||
|
export const VIOLET = '#7c3aed';
|
||||||
|
export const GREEN = '#16a34a';
|
||||||
|
|
||||||
|
/** Tag color + short label per activity-stream kind. */
|
||||||
|
export const ACTIVITY_TAG: Record<PodGraphActivityKind, { color: string; label: string }> = {
|
||||||
|
editing: { color: SLATE, label: 'EDITING' },
|
||||||
|
collision: { color: RED, label: 'COLLISION' },
|
||||||
|
intervention: { color: AMBER, label: 'NUDGE' },
|
||||||
|
outcome: { color: GREEN, label: 'OUTCOME' },
|
||||||
|
learned: { color: VIOLET, label: 'LEARNED' },
|
||||||
|
agent: { color: BLUE, label: 'AGENT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||||
|
engineer: BLUE,
|
||||||
|
file: SLATE,
|
||||||
|
feature: AMBER,
|
||||||
|
collision: RED,
|
||||||
|
intervention: VIOLET,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EdgeStyle {
|
||||||
|
c: string;
|
||||||
|
w: number;
|
||||||
|
dash?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EDGE: Record<PodGraphEdge['kind'], EdgeStyle> = {
|
||||||
|
owns: { c: BLUE, w: 2.4 },
|
||||||
|
editing: { c: SLATE_EDGE, w: 1.9 },
|
||||||
|
touches: { c: SLATE_FAINT, w: 1.5 },
|
||||||
|
collides: { c: RED, w: 2.8 },
|
||||||
|
warns: { c: AMBER, w: 2.8 },
|
||||||
|
learned_from: { c: VIOLET, w: 2.4, dash: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Collision/drawing radius for a node — scaled by its 0..1 weight. */
|
||||||
|
export function nodeRadius(node: PodGraphNode): number {
|
||||||
|
const base = node.kind === 'collision' || node.kind === 'intervention' ? 14 : 13;
|
||||||
|
return base + Math.max(0, Math.min(1, node.weight)) * 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusColor(status: string): string {
|
||||||
|
if (status === 'risk') return RED;
|
||||||
|
if (status === 'learned') return VIOLET;
|
||||||
|
if (status === 'active') return BLUE;
|
||||||
|
return 'var(--muted-foreground)';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Mode = 'risk' | 'learn' | 'all';
|
||||||
|
|
||||||
|
export interface Highlight {
|
||||||
|
nodes: Set<string>;
|
||||||
|
edges: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lit set for the current mode/selection. A selected node lights its
|
||||||
|
* incident edges + neighbors; otherwise the mode lights the risk or learning
|
||||||
|
* chain (collision → intervention → learned_from). `all` lights everything.
|
||||||
|
*/
|
||||||
|
export function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
||||||
|
if (selected) {
|
||||||
|
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
||||||
|
return {
|
||||||
|
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
||||||
|
edges: new Set(es.map((e) => e.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mode === 'all') return null;
|
||||||
|
const kinds: PodGraphEdge['kind'][] =
|
||||||
|
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
||||||
|
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
||||||
|
const es = graph.edges.filter(
|
||||||
|
(e) =>
|
||||||
|
kinds.includes(e.kind) ||
|
||||||
|
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
||||||
|
edges: new Set(es.map((e) => e.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinNames(ids: string[], label: (id: string) => string): string {
|
||||||
|
const u = [...new Set(ids)].map(label);
|
||||||
|
if (u.length <= 1) return u[0] ?? '';
|
||||||
|
if (u.length === 2) return `${u[0]} and ${u[1]}`;
|
||||||
|
return `${u.slice(0, -1).join(', ')} and ${u[u.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain-English walk of the flow through a node — what PodMan saw, flagged,
|
||||||
|
* suggested, and learned — so clicking a node explains the path, not just shows
|
||||||
|
* attributes. Built by traversing the node's incident edges.
|
||||||
|
*/
|
||||||
|
export function flowNarrative(graph: PodGraph, nodeId: string): string {
|
||||||
|
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||||
|
const node = byId.get(nodeId);
|
||||||
|
if (!node) return '';
|
||||||
|
const label = (id: string): string => byId.get(id)?.label ?? id;
|
||||||
|
const out = graph.edges.filter((e) => e.source === nodeId);
|
||||||
|
const inc = graph.edges.filter((e) => e.target === nodeId);
|
||||||
|
|
||||||
|
switch (node.kind) {
|
||||||
|
case 'engineer': {
|
||||||
|
const edits = out.filter((e) => e.kind === 'editing').map((e) => e.target);
|
||||||
|
const collisions = out.filter((e) => e.kind === 'collides');
|
||||||
|
const owns = out.filter((e) => e.kind === 'owns').map((e) => label(e.target));
|
||||||
|
const learned = inc.some((e) => e.kind === 'learned_from');
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (edits.length) parts.push(`${node.label} is working in ${joinNames(edits, label)}.`);
|
||||||
|
if (collisions.length)
|
||||||
|
parts.push(
|
||||||
|
`PodMan flagged ${collisions.length} overlap${collisions.length === 1 ? '' : 's'} involving ${node.label}.`,
|
||||||
|
);
|
||||||
|
if (learned)
|
||||||
|
parts.push(
|
||||||
|
`From an accepted intervention PodMan learned ${node.label} owns ${owns[0] ?? 'this file'} — retained across sessions.`,
|
||||||
|
);
|
||||||
|
else if (owns.length) parts.push(`PodMan has ${node.label} owning ${joinNames(owns, (s) => s)}.`);
|
||||||
|
return parts.join(' ') || `${node.label} has no active flow right now.`;
|
||||||
|
}
|
||||||
|
case 'file': {
|
||||||
|
const editors = inc.filter((e) => e.kind === 'editing' || e.kind === 'owns').map((e) => e.source);
|
||||||
|
const hasCollision = out.some((e) => e.kind === 'touches');
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (editors.length) parts.push(`${node.label} is being edited by ${joinNames(editors, label)}.`);
|
||||||
|
if (hasCollision)
|
||||||
|
parts.push('Two of those edits overlap before push, so PodMan opened a collision on it.');
|
||||||
|
return parts.join(' ') || node.summary || node.label;
|
||||||
|
}
|
||||||
|
case 'collision': {
|
||||||
|
const engineers = inc.filter((e) => e.kind === 'collides').map((e) => e.source);
|
||||||
|
const fileEdge = inc.find((e) => e.kind === 'touches');
|
||||||
|
const file = fileEdge ? label(fileEdge.source) : 'the same file';
|
||||||
|
const intervention = out.find((e) => e.kind === 'warns');
|
||||||
|
let s = `${joinNames(engineers, label) || 'Two engineers'} are both editing ${file} before pushing — the overlap git can't see.`;
|
||||||
|
if (intervention) s += ` PodMan stepped in and suggested a ${label(intervention.target)}.`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
case 'intervention': {
|
||||||
|
const colEdge = inc.find((e) => e.kind === 'warns');
|
||||||
|
const learned = out.find((e) => e.kind === 'learned_from');
|
||||||
|
// Resolve the collision's underlying file via its touches edge (file → collision).
|
||||||
|
let file = '';
|
||||||
|
if (colEdge) {
|
||||||
|
const fileEdge = graph.edges.find((e) => e.kind === 'touches' && e.target === colEdge.source);
|
||||||
|
file = fileEdge ? label(fileEdge.source) : '';
|
||||||
|
}
|
||||||
|
let s = `PodMan offered a ${node.label}${file ? ` for the overlap on ${file}` : ''}.`;
|
||||||
|
if (learned)
|
||||||
|
s += ` The pod accepted it, so PodMan learned ${label(learned.target)} owns ${file || 'the file'} — the graph got sharper.`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
case 'feature': {
|
||||||
|
const contributors = inc.filter((e) => e.kind === 'owns' || e.kind === 'touches').map((e) => e.source);
|
||||||
|
return contributors.length
|
||||||
|
? `${node.label} is built on work by ${joinNames(contributors, label)}.`
|
||||||
|
: node.summary || node.label;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return node.summary ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short explainer for the current view when nothing is selected. */
|
||||||
|
export function modeBlurb(mode: Mode): string {
|
||||||
|
if (mode === 'learn')
|
||||||
|
return 'The violet learned_from links are ownership PodMan kept from accepted interventions — the graph sharpens every session.';
|
||||||
|
if (mode === 'all')
|
||||||
|
return 'Everyone, every file, and every collision and intervention PodMan is tracking for this pod.';
|
||||||
|
return 'The lit path: files where two editors collide before push → the nudge PodMan sent → what it learned.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||||
|
{ label: 'engineer', swatch: { background: BLUE } },
|
||||||
|
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
|
||||||
|
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
|
||||||
|
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
|
||||||
|
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EDGE_LEGEND: Array<{ label: string; color: string; dash?: boolean }> = [
|
||||||
|
{ label: 'collides', color: RED },
|
||||||
|
{ label: 'warns', color: AMBER },
|
||||||
|
{ label: 'learned_from', color: VIOLET, dash: true },
|
||||||
|
{ label: 'owns', color: BLUE },
|
||||||
|
{ label: 'editing', color: SLATE_EDGE },
|
||||||
|
{ label: 'touches', color: SLATE_FAINT },
|
||||||
|
];
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* A tiny dependency-free force-directed layout — the same family of forces as
|
||||||
|
* d3-force (charge repulsion, link springs, centering, collision) integrated
|
||||||
|
* with velocity-Verlet and an annealing `alpha`. Kept in-house so the dynamic
|
||||||
|
* graph adds no new package / lockfile churn to a fast-moving shared `main`.
|
||||||
|
*
|
||||||
|
* Usage: `setData()` (diff-preserving — existing nodes keep their position),
|
||||||
|
* then drive `tick()` from a requestAnimationFrame loop until `settled()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SimNodeInput {
|
||||||
|
id: string;
|
||||||
|
/** Drawing/collision radius. */
|
||||||
|
radius: number;
|
||||||
|
/** Initial position hint (e.g. the server layout), used only for new nodes. */
|
||||||
|
seedX: number;
|
||||||
|
seedY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimLinkInput {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
/** Preferred rest length of the spring. */
|
||||||
|
distance: number;
|
||||||
|
/** 0..1 spring strength. */
|
||||||
|
strength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimNode {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
/** When non-null the node is pinned (dragged) and forces don't move it. */
|
||||||
|
fx: number | null;
|
||||||
|
fy: number | null;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALPHA_MIN = 0.001;
|
||||||
|
const ALPHA_DECAY = 1 - Math.pow(ALPHA_MIN, 1 / 300); // settle in ~300 ticks
|
||||||
|
const FRICTION = 0.62; // velocity retained per tick
|
||||||
|
const REPEL = 4400; // charge repulsion strength — must dominate centering or the graph collapses
|
||||||
|
const LINK_K = 0.45; // spring stiffness multiplier
|
||||||
|
const CENTER_STRENGTH = 0.014; // gentle positional pull — only keeps the cloud roughly centered
|
||||||
|
const RECENTER = 0.5; // per-tick centroid recentering (no compression, keeps graph framed)
|
||||||
|
const COLLIDE_PAD = 12;
|
||||||
|
const COLLIDE_STRENGTH = 1; // hard separation so linked nodes never stack
|
||||||
|
const COLLIDE_ITERS = 2;
|
||||||
|
const BOUND_PAD = 30; // keep nodes this far inside the canvas edges
|
||||||
|
|
||||||
|
export class ForceSim {
|
||||||
|
nodes: SimNode[] = [];
|
||||||
|
links: SimLinkInput[] = [];
|
||||||
|
alpha = 1;
|
||||||
|
private byId = new Map<string, SimNode>();
|
||||||
|
private alphaTarget = 0;
|
||||||
|
private center: { x: number; y: number };
|
||||||
|
private width: number;
|
||||||
|
private height: number;
|
||||||
|
|
||||||
|
constructor(width: number, height: number) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.center = { x: width / 2, y: height / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
settled(): boolean {
|
||||||
|
return this.alpha < ALPHA_MIN && this.alphaTarget === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
reheat(a = 0.7): void {
|
||||||
|
this.alpha = Math.max(this.alpha, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hold the simulation warm while dragging, then release. */
|
||||||
|
setActive(active: boolean): void {
|
||||||
|
this.alphaTarget = active ? 0.18 : 0;
|
||||||
|
if (active) this.reheat(0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): SimNode | undefined {
|
||||||
|
return this.byId.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pin(id: string, x: number, y: number): void {
|
||||||
|
const n = this.byId.get(id);
|
||||||
|
if (n) {
|
||||||
|
n.fx = x;
|
||||||
|
n.fy = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unpin(id: string): void {
|
||||||
|
const n = this.byId.get(id);
|
||||||
|
if (n) {
|
||||||
|
n.fx = null;
|
||||||
|
n.fy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the graph, preserving the positions/pins of nodes that persist. */
|
||||||
|
setData(nodeInputs: SimNodeInput[], linkInputs: SimLinkInput[]): { added: string[] } {
|
||||||
|
const prev = this.byId;
|
||||||
|
const next = new Map<string, SimNode>();
|
||||||
|
const added: string[] = [];
|
||||||
|
for (const inp of nodeInputs) {
|
||||||
|
const old = prev.get(inp.id);
|
||||||
|
if (old) {
|
||||||
|
old.radius = inp.radius;
|
||||||
|
next.set(inp.id, old);
|
||||||
|
} else {
|
||||||
|
next.set(inp.id, {
|
||||||
|
id: inp.id,
|
||||||
|
x: inp.seedX + (Math.random() - 0.5) * 14,
|
||||||
|
y: inp.seedY + (Math.random() - 0.5) * 14,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
fx: null,
|
||||||
|
fy: null,
|
||||||
|
radius: inp.radius,
|
||||||
|
});
|
||||||
|
added.push(inp.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.byId = next;
|
||||||
|
this.nodes = [...next.values()];
|
||||||
|
this.links = linkInputs.filter((l) => next.has(l.source) && next.has(l.target));
|
||||||
|
return { added };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance one step. Returns false when already settled (no work done). */
|
||||||
|
tick(): boolean {
|
||||||
|
if (this.settled()) return false;
|
||||||
|
this.alpha += (this.alphaTarget - this.alpha) * ALPHA_DECAY;
|
||||||
|
const a = this.alpha;
|
||||||
|
this.applyCharge(a);
|
||||||
|
this.applyLinks(a);
|
||||||
|
this.applyCenter(a);
|
||||||
|
for (let k = 0; k < COLLIDE_ITERS; k++) this.applyCollide();
|
||||||
|
const maxX = this.width - BOUND_PAD;
|
||||||
|
const maxY = this.height - BOUND_PAD;
|
||||||
|
for (const n of this.nodes) {
|
||||||
|
if (n.fx != null) {
|
||||||
|
n.x = n.fx;
|
||||||
|
n.vx = 0;
|
||||||
|
} else {
|
||||||
|
n.vx *= FRICTION;
|
||||||
|
n.x += n.vx;
|
||||||
|
if (n.x < BOUND_PAD) {
|
||||||
|
n.x = BOUND_PAD;
|
||||||
|
n.vx = 0;
|
||||||
|
} else if (n.x > maxX) {
|
||||||
|
n.x = maxX;
|
||||||
|
n.vx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.fy != null) {
|
||||||
|
n.y = n.fy;
|
||||||
|
n.vy = 0;
|
||||||
|
} else {
|
||||||
|
n.vy *= FRICTION;
|
||||||
|
n.y += n.vy;
|
||||||
|
if (n.y < BOUND_PAD) {
|
||||||
|
n.y = BOUND_PAD;
|
||||||
|
n.vy = 0;
|
||||||
|
} else if (n.y > maxY) {
|
||||||
|
n.y = maxY;
|
||||||
|
n.vy = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCharge(alpha: number): void {
|
||||||
|
const ns = this.nodes;
|
||||||
|
for (let i = 0; i < ns.length; i++) {
|
||||||
|
const a = ns[i];
|
||||||
|
if (!a) continue;
|
||||||
|
for (let j = i + 1; j < ns.length; j++) {
|
||||||
|
const b = ns[j];
|
||||||
|
if (!b) continue;
|
||||||
|
let dx = b.x - a.x;
|
||||||
|
let dy = b.y - a.y;
|
||||||
|
let d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 === 0) {
|
||||||
|
dx = (j - i) * 0.5;
|
||||||
|
dy = (i + 1) * 0.4;
|
||||||
|
d2 = dx * dx + dy * dy;
|
||||||
|
}
|
||||||
|
const dist = Math.sqrt(d2);
|
||||||
|
const force = (REPEL * alpha) / d2;
|
||||||
|
const ux = dx / dist;
|
||||||
|
const uy = dy / dist;
|
||||||
|
a.vx -= ux * force;
|
||||||
|
a.vy -= uy * force;
|
||||||
|
b.vx += ux * force;
|
||||||
|
b.vy += uy * force;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLinks(alpha: number): void {
|
||||||
|
for (const link of this.links) {
|
||||||
|
const s = this.byId.get(link.source);
|
||||||
|
const t = this.byId.get(link.target);
|
||||||
|
if (!s || !t) continue;
|
||||||
|
let dx = t.x - s.x;
|
||||||
|
let dy = t.y - s.y;
|
||||||
|
let d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 === 0) {
|
||||||
|
dx = 0.5;
|
||||||
|
dy = 0.5;
|
||||||
|
d2 = 0.5;
|
||||||
|
}
|
||||||
|
const dist = Math.sqrt(d2);
|
||||||
|
const k = ((dist - link.distance) / dist) * alpha * link.strength * LINK_K;
|
||||||
|
const mx = dx * k * 0.5;
|
||||||
|
const my = dy * k * 0.5;
|
||||||
|
s.vx += mx;
|
||||||
|
s.vy += my;
|
||||||
|
t.vx -= mx;
|
||||||
|
t.vy -= my;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCenter(alpha: number): void {
|
||||||
|
const n = this.nodes.length;
|
||||||
|
if (!n) return;
|
||||||
|
// Recenter the whole cloud so its centroid sits at canvas center (this does
|
||||||
|
// NOT compress the layout — repulsion/links set the spread), plus a gentle
|
||||||
|
// positional pull so stray/isolated nodes don't park against the edge.
|
||||||
|
let cx = 0;
|
||||||
|
let cy = 0;
|
||||||
|
for (const nd of this.nodes) {
|
||||||
|
cx += nd.x;
|
||||||
|
cy += nd.y;
|
||||||
|
}
|
||||||
|
cx = (this.center.x - cx / n) * RECENTER;
|
||||||
|
cy = (this.center.y - cy / n) * RECENTER;
|
||||||
|
for (const nd of this.nodes) {
|
||||||
|
if (nd.fx == null) {
|
||||||
|
nd.x += cx;
|
||||||
|
nd.vx += (this.center.x - nd.x) * CENTER_STRENGTH * alpha;
|
||||||
|
}
|
||||||
|
if (nd.fy == null) {
|
||||||
|
nd.y += cy;
|
||||||
|
nd.vy += (this.center.y - nd.y) * CENTER_STRENGTH * alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCollide(): void {
|
||||||
|
const ns = this.nodes;
|
||||||
|
for (let i = 0; i < ns.length; i++) {
|
||||||
|
const a = ns[i];
|
||||||
|
if (!a) continue;
|
||||||
|
for (let j = i + 1; j < ns.length; j++) {
|
||||||
|
const b = ns[j];
|
||||||
|
if (!b) continue;
|
||||||
|
let dx = b.x - a.x;
|
||||||
|
let dy = b.y - a.y;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
const min = a.radius + b.radius + COLLIDE_PAD;
|
||||||
|
if (d2 >= min * min) continue;
|
||||||
|
let dist = Math.sqrt(d2);
|
||||||
|
if (dist === 0) {
|
||||||
|
dx = j - i;
|
||||||
|
dy = i + 1;
|
||||||
|
dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
}
|
||||||
|
const push = ((min - dist) / dist) * 0.5 * COLLIDE_STRENGTH;
|
||||||
|
const ox = dx * push;
|
||||||
|
const oy = dy * push;
|
||||||
|
if (a.fx == null) a.x -= ox;
|
||||||
|
if (a.fy == null) a.y -= oy;
|
||||||
|
if (b.fx == null) b.x += ox;
|
||||||
|
if (b.fy == null) b.y += oy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,8 @@ export async function fetchPodGraph(podId: string): Promise<PodGraph> {
|
|||||||
if (!res.ok) throw new Error(`graph request failed: ${res.status}`);
|
if (!res.ok) throw new Error(`graph request failed: ${res.status}`);
|
||||||
return res.json() as Promise<PodGraph>;
|
return res.json() as Promise<PodGraph>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** WebSocket URL for the live event bus — used to nudge the graph to refetch. */
|
||||||
|
export function backendEventsUrl(): string {
|
||||||
|
return `${BACKEND_URL.replace(/^http/, 'ws')}/api/events`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user