SPB Git

spb/akos

Public
3.4 KB · 55 lines tsx
1import { useMemo } from "react";2import { useNavigate } from "react-router";3import type { Graph } from "../api";45const ORDER = ["provider", "api", "version", "endpoint", "akp", "credential", "execution", "raw", "dataset", "transformation", "export", "project", "debt", "recipe"];6export function routeFor(id: string): string | null {7  const [t, i] = id.split(":", 2);8  switch (t) {9    case "api": return `/apis/${i}`; case "provider": return "/apis"; case "akp": return `/akps/${i}`; case "execution": return `/executions/${i}`; case "dataset": return `/datasets/${i}`; case "project": return `/projects/${i}`; case "credential": return `/credentials?focus=${i}`; case "debt": return `/debt?focus=${i}`; case "version": return `/versions/${i}`; case "endpoint": return `/endpoints/${i}`; default: return null;10  }11}1213/** Layered layout: one column per node type (fixed order), nodes stacked; edges as cubic curves. Scrollable on mobile. */14export function LayeredGraph({ graph, nodeW = 150, nodeH = 30 }: { graph: Graph; nodeW?: number; nodeH?: number }) {15  const nav = useNavigate();16  const layout = useMemo(() => {17    const types = Array.from(new Set(graph.nodes.map((n) => n.type))).sort((a, b) => (ORDER.indexOf(a) === -1 ? 99 : ORDER.indexOf(a)) - (ORDER.indexOf(b) === -1 ? 99 : ORDER.indexOf(b)));18    const colX = new Map(types.map((t, i) => [t, 20 + i * (nodeW + 60)]));19    const pos = new Map<string, { x: number; y: number }>();20    const counts = new Map<string, number>();21    for (const n of graph.nodes) {22      const c = counts.get(n.type) ?? 0; counts.set(n.type, c + 1);23      pos.set(n.id, { x: colX.get(n.type)!, y: 34 + c * (nodeH + 10) });24    }25    const width = 40 + types.length * (nodeW + 60), height = 50 + Math.max(1, ...Array.from(counts.values())) * (nodeH + 10);26    return { types, colX, pos, width, height };27  }, [graph, nodeW, nodeH]);28  if (!graph.nodes.length) return <div className="empty">No nodes to draw.</div>;29  return (30    <div className="graphwrap">31      <svg className="graph" width={layout.width} height={layout.height} role="img" aria-label="Knowledge graph">32        {layout.types.map((t) => <text key={t} className="col" x={layout.colX.get(t)!} y={18}>{t}</text>)}33        {graph.edges.map((e, i) => {34          const a = layout.pos.get(e.from), b = layout.pos.get(e.to);35          if (!a || !b) return null;36          const x1 = a.x + nodeW, y1 = a.y + nodeH / 2, x2 = b.x, y2 = b.y + nodeH / 2;37          const left = x2 < x1;38          const d = left ? `M${a.x},${y1} C${a.x - 40},${y1} ${x2 + nodeW + 40},${y2} ${x2 + nodeW},${y2}` : `M${x1},${y1} C${x1 + 30},${y1} ${x2 - 30},${y2} ${x2},${y2}`;39          return <path key={i} className="edge" d={d}><title>{e.relation}</title></path>;40        })}41        {graph.nodes.map((n) => {42          const p = layout.pos.get(n.id)!; const r = routeFor(n.id);43          return (44            <g key={n.id} className="node" transform={`translate(${p.x},${p.y})`} onClick={() => r && nav(r)} tabIndex={0} role={r ? "link" : undefined} onKeyDown={(e) => { if (e.key === "Enter" && r) nav(r); }}>45              <rect width={nodeW} height={nodeH} />46              <text x={8} y={nodeH / 2 + 4}>{n.label.length > 20 ? n.label.slice(0, 19) + "…" : n.label}</text>47              <title>{n.id}{Object.entries(n).filter(([k]) => !["id", "type", "label"].includes(k)).map(([k, v]) => `\n${k}: ${String(v)}`).join("")}</title>48            </g>49          );50        })}51      </svg>52    </div>53  );54}55
Files · main
Menu

Theme