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:
sb-iam
2026-06-28 02:52:26 -07:00
parent 604bf9d5ac
commit b8b6b16531
11 changed files with 1214 additions and 264 deletions
+8 -6
View File
@@ -11,21 +11,23 @@ export function createDemoPodGraph(podId: string): PodGraph {
return {
podId,
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: [
{
label: 'Learned owners',
value: '5',
detail: 'Ownership edges retained from accepted interventions.',
value: '3',
detail: 'Distinct owners retained from accepted interventions.',
},
{
label: 'Open risk paths',
value: '2',
detail: 'auth.ts and the memory API have converging editors.',
value: '1',
detail: 'File with two or more converging editors.',
},
{
label: 'Accept rate',
value: '86%',
detail: 'Interventions accepted this session (+14%).',
value: '100%',
detail: 'Interventions accepted vs total this session.',
},
],
loop: {
+23 -5
View File
@@ -502,6 +502,7 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
const totalOutcomes = outcomeDocs.length;
// Raw distinct collision signatures — kept for the learning-loop throughput view.
const riskPaths = new Set(
collisionDocs.map(
(col) =>
@@ -509,21 +510,38 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
),
).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[] = [
{
label: 'Learned owners',
value: String(acceptedReal),
detail: 'Ownership retained from accepted interventions.',
value: String(learnedOwners),
detail: 'Distinct owners retained from accepted interventions.',
},
{
label: 'Open risk paths',
value: String(riskPaths),
detail: 'Files with two or more converging editors.',
value: String(openRiskPaths),
detail: `${openRiskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`,
},
{
label: 'Accept rate',
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;
+150 -253
View File
@@ -1,111 +1,36 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
import { fetchPodGraph } from '../lib/graph.js';
import { useEffect, useMemo, useState } from 'react';
import type { PodGraph } from '@podman/shared';
import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js';
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
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
const BLUE = '#2563eb';
const SLATE = '#475569';
const SLATE_EDGE = '#94a3b8';
const SLATE_FAINT = '#cbd5e1';
const AMBER = '#d97706';
const RED = '#dc2626';
const VIOLET = '#7c3aed';
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
engineer: BLUE,
file: SLATE,
feature: AMBER,
collision: RED,
intervention: VIOLET,
};
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;
// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
const GRAPH_CSS = `
.pm-node{cursor:grab;transition:opacity .25s ease}
.pm-node:active{cursor:grabbing}
.pm-edge{transition:opacity .25s ease}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none;
paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round}
.pm-dim{opacity:.14}
.pm-enter{animation:pm-fade .45s ease}
.pm-dash{animation:pm-flow 1s linear infinite}
.pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
@keyframes pm-fade{from{opacity:0}to{opacity:1}}
@keyframes pm-flow{to{stroke-dashoffset:-26}}
@keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
@media (prefers-reduced-motion:reduce){
.pm-enter,.pm-dash,.pm-pulse{animation:none}
}
}
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 }) {
const [graph, setGraph] = useState<PodGraph | null>(null);
@@ -115,198 +40,170 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
useEffect(() => {
let alive = true;
let nudge: number | null = null;
setGraph(null);
setError(null);
fetchPodGraph(podId)
.then((g) => alive && setGraph(g))
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
setSelected(null);
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 () => {
alive = false;
window.clearInterval(poll);
if (nudge != null) window.clearTimeout(nudge);
ws?.close();
};
}, [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 sel = selected ? nodeById.get(selected) : undefined;
const relCount = selected
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
: 0;
// A selected node can vanish across a poll/WS refresh. Ignore a stale id so the
// graph doesn't dim entirely (highlightFor would otherwise light only a dead id).
const liveSelected = selected && nodeById.has(selected) ? selected : null;
useEffect(() => {
if (selected && graph && !nodeById.has(selected)) setSelected(null);
}, [graph, nodeById, selected]);
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
const highlight = useMemo(
() => (graph ? highlightFor(graph, mode, liveSelected) : null),
[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) {
setMode(next);
setSelected(null);
}
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
return (
<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">
<style>{`
.pm-node{cursor:pointer}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
.pm-dim{opacity:.18;transition:opacity .25s}
`}</style>
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
<div className="flex items-center justify-between border-b px-5 py-4">
<div>
<h2 className="text-base font-medium">Team memory</h2>
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
{/* Header */}
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="font-heading text-base font-medium">Team memory</h2>
<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">
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
Live
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
What PodMan learned · {podId}
</p>
</div>
<Button variant="outline" size="sm" onClick={onClose}>
Pods
</Button>
</div>
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
Risk path
</Button>
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
Learning edges
</Button>
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
Whole graph
</Button>
{/* Toggles */}
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
<ToggleGroup
type="single"
value={mode}
onValueChange={(v) => v && pick(v as Mode)}
variant="outline"
size="sm"
>
<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>
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
{!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 && (
<>
<div className="grid lg:grid-cols-[190px_1fr_250px]">
<div className="space-y-3 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Workflow metrics
</p>
{graph.metrics.map((m) => (
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
<p className="text-2xl font-medium tabular-nums">{m.value}</p>
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
{m.label}
</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
</div>
))}
{/* Metrics · graph · learning loop */}
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
<MetricsRail metrics={graph.metrics} />
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
<GraphCanvas
graph={graph}
highlight={highlight}
selected={liveSelected}
onSelect={setSelected}
/>
</div>
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
<svg
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>
{graph.loop?.steps?.length ? <LearningLoop loop={graph.loop} /> : <div />}
</div>
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
{sel ? (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{sel.kind}
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.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(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>
</>
)}
{/* Activity stream · selected node */}
<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">
<ActivityStream events={graph.activity ?? []} />
</div>
<div className="rounded-xl border bg-muted/40 p-4">
<SelectedNodePanel node={sel} relCount={relCount} flow={flow} mode={mode} />
</div>
</div>
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
{LEGEND.map((l) => (
{/* Legend */}
<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 className="inline-block size-3" style={l.swatch} />
{l.label}
</span>
))}
<span className="flex items-center gap-1.5">
<span className="inline-block h-[3px] w-3" style={{ background: RED }} />
collides
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
learned_from
</span>
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
{EDGE_LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5">
<span
className="inline-block h-[3px] w-3.5"
style={
l.dash
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
: { background: l.color }
}
/>
{l.label}
</span>
))}
</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>
);
}
+210
View File
@@ -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 },
];
+284
View File
@@ -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;
}
}
}
}
+5
View File
@@ -8,3 +8,8 @@ export async function fetchPodGraph(podId: string): Promise<PodGraph> {
if (!res.ok) throw new Error(`graph request failed: ${res.status}`);
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`;
}