feat(graph): dynamic force-directed Team-memory graph + learning-loop & activity rails
Rebuild the live "Team memory" view so the light/real-data version matches the dark Bauhaus mock and the graph is genuinely DYNAMIC instead of dead static columns. Frontend (frontend/src/components/graph/*, composed into GraphView.tsx): - forceSim.ts: a tiny dependency-free force layout (charge repulsion, link springs, centroid recentering + gentle pull, 2-pass collision, bounds clamp, alpha anneal). No d3-force dependency added — keeps the shared pnpm-lock untouched so CI's frozen-lockfile install and the deploy path are unaffected. - GraphCanvas.tsx: SVG render driven by the sim — draggable + pinnable nodes (double-click to release), curved edges that fan parallel pairs, weight-sized geometric node shapes, fade-in on new nodes/edges, animated learned_from dash, risk-path lighting with the rest dimmed, label collision-avoidance. - MetricsRail / LearningLoop / ActivityStream / SelectedNodePanel / encoding.ts: the mock's rails + stream + detail panel, light shadcn (ToggleGroup, ScrollArea, Badge, Button) on theme tokens; only the SVG is bespoke. - GraphView polls /api/pods/:id/graph every 5s and diffs (positions preserved across refreshes), with a best-effort ws /api/events nudge. A stale selection (node gone across a poll) is dropped so the canvas can't dim entirely. Backend (additive — materializer de-noise untouched): - live.ts: buildLoop() (observe→store→predict→outcome→adapt counts, deepest-recent stage active) and buildActivity() (time-sorted typed feed, same isFilePath / ENGINEER_NOISE / signature de-noise) emitted alongside nodes/edges/metrics. - demo.ts: fallback loop + activity so the panels render on the demo path. - shared/src/graph.ts: additive optional PodGraph.loop / .activity + LearningStage / ActivityEvent types. Verified: pnpm lint + -r typecheck + -r build pass; Playwright on the dev build confirmed force layout (distinct positions, ticks on load under StrictMode), drag, risk-mode dimming (opacity 0.14), selection panel, legend, and no overlaps on both the clean demo and the 31-node live hairball. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,45 @@ import type { PodGraph } from '@podman/shared';
|
||||
* auth* — is the continual-learning story the demo lights up.
|
||||
*/
|
||||
export function createDemoPodGraph(podId: string): PodGraph {
|
||||
const base = Date.now();
|
||||
const at = (secAgo: number): string => new Date(base - secAgo * 1000).toISOString();
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
loop: [
|
||||
{ key: 'observe', title: 'OBSERVE', value: '5', detail: '~5/s vision contexts', active: false },
|
||||
{ key: 'store', title: 'STORE', value: '124', detail: 'memory vectors · Atlas', active: false },
|
||||
{ key: 'predict', title: 'PREDICT', value: '2', detail: 'collisions flagged', active: true },
|
||||
{ key: 'outcome', title: 'OUTCOME', value: '1/0', detail: 'accepted · dismissed', active: false },
|
||||
{ key: 'adapt', title: 'ADAPT', value: '5', detail: 'learned owners', active: false },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: 'demo-learn',
|
||||
at: at(20),
|
||||
kind: 'learned_from',
|
||||
text: 'Memory updated: Karti owns auth.ts (confidence ↑)',
|
||||
},
|
||||
{ id: 'demo-out', at: at(24), kind: 'outcome', text: 'Intervention accepted by the pod' },
|
||||
{
|
||||
id: 'demo-warn',
|
||||
at: at(40),
|
||||
kind: 'warns',
|
||||
text: 'PodMan: "Karti & Yahya are both in auth.ts — open a sync PR?" → card sent',
|
||||
},
|
||||
{
|
||||
id: 'demo-col',
|
||||
at: at(58),
|
||||
kind: 'collision',
|
||||
text: 'Critical overlap on auth.ts · Karti + Yahya',
|
||||
},
|
||||
{
|
||||
id: 'demo-edit',
|
||||
at: at(72),
|
||||
kind: 'editing',
|
||||
text: 'Yahya opened auth.ts — unpushed changes',
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
label: 'Learned owners',
|
||||
|
||||
@@ -6,6 +6,13 @@ import type {
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
LearningStage,
|
||||
LearningStageKey,
|
||||
ActivityEvent,
|
||||
EngineerContext,
|
||||
Collision,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
} from '@podman/shared';
|
||||
import { collections, getGitStates, getDb } from '../memory/db.js';
|
||||
|
||||
@@ -148,6 +155,185 @@ function layout(nodes: PodGraphNode[]): void {
|
||||
|
||||
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 };
|
||||
|
||||
/** Parse any timestamp-ish value to epoch ms (0 when missing/unparseable). */
|
||||
function ms(t: string | Date | null | undefined): number {
|
||||
if (!t) return 0;
|
||||
const v = new Date(t).getTime();
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
const OBSERVE_WINDOW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Live counts for the learning-loop rail (observe→store→predict→outcome→adapt).
|
||||
* The "active" stage is the one whose latest underlying event is most recent —
|
||||
* with deeper stages winning ties so the rail lights up at the furthest point
|
||||
* the pod reached this session. Additive: derived from already-fetched docs.
|
||||
*/
|
||||
function buildLoop(opts: {
|
||||
now: number;
|
||||
observations: EngineerContext[];
|
||||
collisions: Collision[];
|
||||
outcomes: InterventionOutcome[];
|
||||
riskPaths: number;
|
||||
vectorCount: number;
|
||||
learnedOwners: number;
|
||||
}): LearningStage[] {
|
||||
const { now, observations, collisions, outcomes, riskPaths, vectorCount, learnedOwners } = opts;
|
||||
|
||||
const recentObs = observations.filter((o) => now - ms(o.observedAt) < OBSERVE_WINDOW_MS).length;
|
||||
const rate = (recentObs / 60).toFixed(1);
|
||||
const accepted = outcomes.filter((o) => o.accepted).length;
|
||||
const dismissed = outcomes.filter((o) => !o.accepted).length;
|
||||
|
||||
// Latest event time per stage; `store` sits just behind `predict` so a shared
|
||||
// collision timestamp resolves to PREDICT rather than STORE.
|
||||
const latestObs = Math.max(0, ...observations.map((o) => ms(o.observedAt)));
|
||||
const latestCol = Math.max(0, ...collisions.map((c) => ms(c.detectedAt)));
|
||||
const latestOut = Math.max(0, ...outcomes.map((o) => ms(o.recordedAt)));
|
||||
const latestAdapt = Math.max(
|
||||
0,
|
||||
...outcomes.filter((o) => o.accepted && o.wasRealCollision).map((o) => ms(o.recordedAt)),
|
||||
);
|
||||
|
||||
const refs: Array<[LearningStageKey, number]> = [
|
||||
['observe', latestObs],
|
||||
['store', latestCol ? latestCol - 1 : 0],
|
||||
['predict', latestCol],
|
||||
['outcome', latestOut],
|
||||
['adapt', latestAdapt],
|
||||
];
|
||||
let activeKey: LearningStageKey = 'observe';
|
||||
let best = 0;
|
||||
for (const [k, t] of refs) {
|
||||
if (t > 0 && t >= best) {
|
||||
best = t;
|
||||
activeKey = k;
|
||||
}
|
||||
}
|
||||
|
||||
const stages: Array<Omit<LearningStage, 'active'>> = [
|
||||
{ key: 'observe', title: 'OBSERVE', value: String(recentObs), detail: `~${rate}/s vision contexts` },
|
||||
{ key: 'store', title: 'STORE', value: String(vectorCount), detail: 'memory vectors · Atlas' },
|
||||
{
|
||||
key: 'predict',
|
||||
title: 'PREDICT',
|
||||
value: String(riskPaths),
|
||||
detail: `${riskPaths === 1 ? 'collision' : 'collisions'} flagged`,
|
||||
},
|
||||
{ key: 'outcome', title: 'OUTCOME', value: `${accepted}/${dismissed}`, detail: 'accepted · dismissed' },
|
||||
{
|
||||
key: 'adapt',
|
||||
title: 'ADAPT',
|
||||
value: String(learnedOwners),
|
||||
detail: `learned owner${learnedOwners === 1 ? '' : 's'}`,
|
||||
},
|
||||
];
|
||||
return stages.map((s) => ({ ...s, active: s.key === activeKey }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge + time-sort recent events into the activity stream feed. Reuses the same
|
||||
* de-noise (isFilePath / ENGINEER_NOISE / signature collapse) as the graph so
|
||||
* the feed never shows junk paths or test-artifact engineers. Capped to 8.
|
||||
*/
|
||||
function buildActivity(opts: {
|
||||
observations: EngineerContext[];
|
||||
collisions: Collision[];
|
||||
interventions: Intervention[];
|
||||
outcomes: InterventionOutcome[];
|
||||
ownership: Record<string, string>;
|
||||
}): ActivityEvent[] {
|
||||
const { observations, collisions, interventions, outcomes, ownership } = opts;
|
||||
const cleanEng = (n: string): boolean => Boolean(n) && !ENGINEER_NOISE.test(n);
|
||||
const out: ActivityEvent[] = [];
|
||||
|
||||
// editing — newest observation per (engineer, file); observations arrive desc.
|
||||
const seenEdit = new Set<string>();
|
||||
for (const o of observations) {
|
||||
if (!o.engineerId || !cleanEng(o.engineerId)) continue;
|
||||
const file = o.currentFile ? normalizeFile(o.currentFile) : '';
|
||||
if (!isFilePath(file)) continue;
|
||||
const key = `${o.engineerId.toLowerCase()}|${file}`;
|
||||
if (seenEdit.has(key)) continue;
|
||||
seenEdit.add(key);
|
||||
out.push({
|
||||
id: `edit:${o.engineerId}:${file}`,
|
||||
at: o.observedAt,
|
||||
kind: 'editing',
|
||||
text: `${o.engineerId} opened ${shortLabel(file)}${
|
||||
o.hasUnpushedChanges ? ' — unpushed changes' : ''
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
// collision — collapse by signature, newest first.
|
||||
const seenCol = new Set<string>();
|
||||
for (const c of collisions) {
|
||||
const file = normalizeFile(c.file);
|
||||
if (!isFilePath(file)) continue;
|
||||
const sig = (c as { memorySignature?: string }).memorySignature ?? `${file}#${c.symbol ?? ''}`;
|
||||
if (seenCol.has(sig)) continue;
|
||||
seenCol.add(sig);
|
||||
const engs = c.engineers.filter(cleanEng);
|
||||
if (!engs.length) continue;
|
||||
out.push({
|
||||
id: `col:${c.id}`,
|
||||
at: c.detectedAt,
|
||||
kind: 'collision',
|
||||
text: `${c.severity === 'critical' ? 'Critical overlap' : 'Overlap'} on ${shortLabel(
|
||||
file,
|
||||
)} · ${engs.join(' + ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// warns — interventions PodMan raised.
|
||||
for (const iv of interventions) {
|
||||
if (!iv.message) continue;
|
||||
const msg = iv.message.length > 64 ? `${iv.message.slice(0, 61)}…` : iv.message;
|
||||
out.push({
|
||||
id: `warn:${iv.id}`,
|
||||
at: iv.createdAt,
|
||||
kind: 'warns',
|
||||
text: `PodMan: "${msg}" → card sent`,
|
||||
});
|
||||
}
|
||||
|
||||
// outcome + learned_from — the supervised learning beat.
|
||||
const colById = new Map(collisions.map((c) => [c.id, c]));
|
||||
const ivById = new Map(interventions.map((i) => [i.id, i]));
|
||||
for (const o of outcomes) {
|
||||
if (!o.accepted) continue;
|
||||
out.push({
|
||||
id: `out:${o.interventionId}`,
|
||||
at: o.recordedAt,
|
||||
kind: 'outcome',
|
||||
text: 'Intervention accepted by the pod',
|
||||
});
|
||||
if (!o.wasRealCollision) continue;
|
||||
const iv = ivById.get(o.interventionId);
|
||||
const col = iv ? colById.get(iv.collisionId) : colById.get(o.collisionId);
|
||||
if (!col) continue;
|
||||
const file = normalizeFile(col.file);
|
||||
if (!isFilePath(file)) continue;
|
||||
const owner =
|
||||
(o as { learnedOwner?: string }).learnedOwner ??
|
||||
ownership[file] ??
|
||||
col.engineers.find(cleanEng) ??
|
||||
col.engineers[0];
|
||||
if (!owner) continue;
|
||||
out.push({
|
||||
id: `learn:${o.interventionId}`,
|
||||
at: o.recordedAt,
|
||||
kind: 'learned_from',
|
||||
text: `Memory updated: ${owner} owns ${shortLabel(file)} (confidence ↑)`,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => ms(b.at) - ms(a.at));
|
||||
return out.slice(0, 8);
|
||||
}
|
||||
|
||||
export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
|
||||
const c = await collections();
|
||||
const db = await getDb();
|
||||
@@ -390,11 +576,46 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
},
|
||||
];
|
||||
|
||||
// Stored vectors for the STORE stage: prefer a real memory_vectors count,
|
||||
// fall back to collisions carrying an embedding, then to collision count.
|
||||
let vectorCount = 0;
|
||||
try {
|
||||
vectorCount = await db.collection('memory_vectors').countDocuments({ podId });
|
||||
} catch {
|
||||
/* memory_vectors is optional */
|
||||
}
|
||||
if (!vectorCount)
|
||||
vectorCount = collisionDocs.filter(
|
||||
(c) => (c as { embedding?: number[] }).embedding?.length,
|
||||
).length;
|
||||
if (!vectorCount) vectorCount = collisionDocs.length;
|
||||
|
||||
const learnedOwners = Object.keys(ownership).length || acceptedReal;
|
||||
|
||||
const loop = buildLoop({
|
||||
now,
|
||||
observations,
|
||||
collisions: collisionDocs,
|
||||
outcomes: outcomeDocs,
|
||||
riskPaths,
|
||||
vectorCount,
|
||||
learnedOwners,
|
||||
});
|
||||
const activity = buildActivity({
|
||||
observations,
|
||||
collisions: collisionDocs,
|
||||
interventions: interventionDocs,
|
||||
outcomes: outcomeDocs,
|
||||
ownership,
|
||||
});
|
||||
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
nodes,
|
||||
edges: [...b.edges.values()],
|
||||
metrics,
|
||||
loop,
|
||||
activity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, 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,173 @@ 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;
|
||||
|
||||
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?.length ? (
|
||||
<LearningLoop stages={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} />
|
||||
</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,45 @@
|
||||
import type { ActivityEvent } 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: ActivityEvent[] }) {
|
||||
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 text-foreground/90">{e.text}</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,43 @@
|
||||
import type { LearningStage } 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({ stages }: { stages: LearningStage[] }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Learning loop
|
||||
</p>
|
||||
{stages.map((s, i) => (
|
||||
<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={s.active}
|
||||
style={s.active ? { boxShadow: `inset 0 0 0 1px ${BLUE}55` } : undefined}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute inset-y-0 left-0 w-1 ${s.active ? 'pm-pulse' : ''}`}
|
||||
style={{ background: s.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.title}
|
||||
</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 < stages.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,51 @@
|
||||
import type { PodGraphNode } from '@podman/shared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { statusColor, VIOLET } from './encoding.js';
|
||||
|
||||
export function SelectedNodePanel({
|
||||
node,
|
||||
relCount,
|
||||
}: {
|
||||
node: PodGraphNode | undefined;
|
||||
relCount: number;
|
||||
}) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Continual learning
|
||||
</p>
|
||||
<h3 className="mb-2 font-heading text-base 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, or 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>
|
||||
{node.summary && (
|
||||
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">{node.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type {
|
||||
PodGraph,
|
||||
PodGraphNode,
|
||||
PodGraphEdge,
|
||||
PodGraphNodeKind,
|
||||
ActivityKind,
|
||||
} 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<ActivityKind, { color: string; label: string }> = {
|
||||
editing: { color: SLATE, label: 'EDITING' },
|
||||
collision: { color: RED, label: 'COLLISION' },
|
||||
warns: { color: AMBER, label: 'WARNS' },
|
||||
outcome: { color: GREEN, label: 'OUTCOME' },
|
||||
learned_from: { color: VIOLET, label: 'LEARNED' },
|
||||
};
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
@@ -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}`);
|
||||
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`;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,36 @@ export interface PodGraphMetric {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** The five stages of PodMan's continual-learning loop, in order. */
|
||||
export type LearningStageKey = 'observe' | 'store' | 'predict' | 'outcome' | 'adapt';
|
||||
|
||||
/** One stage of the learning-loop rail (observe→store→predict→outcome→adapt). */
|
||||
export interface LearningStage {
|
||||
key: LearningStageKey;
|
||||
/** UPPERCASE display title, e.g. "OBSERVE". */
|
||||
title: string;
|
||||
/** Headline figure for the stage, e.g. "5/s" or "124". */
|
||||
value: string;
|
||||
/** One-line detail under the title. */
|
||||
detail: string;
|
||||
/** True for the single most-recently-active stage (pulses in the UI). */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Kind of an activity-stream entry (drives the colored tag). */
|
||||
export type ActivityKind = 'editing' | 'collision' | 'warns' | 'outcome' | 'learned_from';
|
||||
|
||||
/** One time-tagged entry in the activity stream. */
|
||||
export interface ActivityEvent {
|
||||
/** Stable id (source doc id + kind) so the UI can animate diffs. */
|
||||
id: string;
|
||||
/** ISO timestamp the event happened. */
|
||||
at: string;
|
||||
kind: ActivityKind;
|
||||
/** Human-readable line, e.g. "Yahya opened auth.ts — unpushed changes". */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A point-in-time render of a pod's team_model. */
|
||||
export interface PodGraph {
|
||||
podId: string;
|
||||
@@ -56,6 +86,10 @@ export interface PodGraph {
|
||||
nodes: PodGraphNode[];
|
||||
edges: PodGraphEdge[];
|
||||
metrics: PodGraphMetric[];
|
||||
/** Continual-learning loop counts (observe→…→adapt). Additive/optional. */
|
||||
loop?: LearningStage[];
|
||||
/** Recent activity feed, most-recent first, capped ~8. Additive/optional. */
|
||||
activity?: ActivityEvent[];
|
||||
}
|
||||
|
||||
/** One node as a standalone document in the `graph_nodes` collection. */
|
||||
|
||||
@@ -18,6 +18,10 @@ export type {
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
LearningStage,
|
||||
LearningStageKey,
|
||||
ActivityEvent,
|
||||
ActivityKind,
|
||||
GraphNodeDoc,
|
||||
GraphEdgeDoc,
|
||||
} from './graph.js';
|
||||
|
||||
Reference in New Issue
Block a user