vtc-service 0.10.13

Service for Verifiable Trust Communities
// Relationships plugin — a connections graph of the community's member-to-member
// trust edges (Verifiable Relationship Credentials, VRCs).
//
// Unlike the recognition graph (external, query-only), member relationships are
// local + enumerable, so we can draw the whole thing. Layout is a deterministic
// circle: members are nodes, each published VRC is a directed edge issuer →
// subject. Click a node to highlight its connections and list its edges.

import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Share2 } from "lucide-react";

import { fetchRelationshipsGraph, type RelationshipsGraph } from "@/lib/api";
import { shorten } from "@/lib/format";

const SIZE = 600;
const C = SIZE / 2;
const R = 240;

interface Placed {
  did: string;
  x: number;
  y: number;
}

export function Relationships() {
  const [selected, setSelected] = useState<string | null>(null);

  const query = useQuery<RelationshipsGraph>({
    queryKey: ["relationships-graph"],
    queryFn: fetchRelationshipsGraph,
  });

  const placed = useMemo<Placed[]>(() => {
    const nodes = query.data?.nodes ?? [];
    const n = nodes.length;
    return nodes.map((node, i) => {
      const a = (i / Math.max(n, 1)) * 2 * Math.PI - Math.PI / 2;
      return { did: node.did, x: C + R * Math.cos(a), y: C + R * Math.sin(a) };
    });
  }, [query.data]);

  const posByDid = useMemo(() => {
    const m = new Map<string, Placed>();
    for (const p of placed) m.set(p.did, p);
    return m;
  }, [placed]);

  const edges = query.data?.edges ?? [];
  const selectedEdges = selected
    ? edges.filter((e) => e.issuerDid === selected || e.subjectDid === selected)
    : [];
  const neighbours = new Set<string>(
    selectedEdges.flatMap((e) => [e.issuerDid, e.subjectDid]),
  );

  const isEmpty = query.data && placed.length === 0;

  return (
    <div className="page">
      <header className="page-header">
        <h2>
          <Share2 size={20} strokeWidth={1.75} /> Relationships
        </h2>
        <p className="muted">
          The community's trust graph: each node is a member, each edge a
          published Verifiable Relationship Credential (VRC), pointing from the
          asserting member to the member they vouched for. Click a node to
          highlight its connections.
        </p>
      </header>

      {query.isPending && (
        <section className="card">
          <p className="muted">Loading…</p>
        </section>
      )}
      {query.isError && (
        <section className="card">
          <p className="muted">Could not load the relationships graph.</p>
        </section>
      )}
      {isEmpty && (
        <section className="card">
          <p className="muted">
            No relationships published yet — members haven't issued any VRCs.
          </p>
        </section>
      )}

      {query.data && placed.length > 0 && (
        <section className="card" style={{ display: "flex", gap: "var(--space-4)", flexWrap: "wrap" }}>
          <svg
            viewBox={`0 0 ${SIZE} ${SIZE}`}
            style={{ width: "min(100%, 560px)", height: "auto" }}
            role="img"
            aria-label="Member relationship graph"
          >
            <defs>
              <marker
                id="rel-arrow"
                viewBox="0 0 10 10"
                refX="9"
                refY="5"
                markerWidth="6"
                markerHeight="6"
                orient="auto-start-reverse"
              >
                <path d="M 0 0 L 10 5 L 0 10 z" fill="var(--border-strong)" />
              </marker>
            </defs>

            {/* Edges */}
            {edges.map((e) => {
              const a = posByDid.get(e.issuerDid);
              const b = posByDid.get(e.subjectDid);
              if (!a || !b) return null;
              const active =
                !selected || e.issuerDid === selected || e.subjectDid === selected;
              return (
                <line
                  key={e.id}
                  x1={a.x}
                  y1={a.y}
                  x2={b.x}
                  y2={b.y}
                  stroke={active ? "var(--brand)" : "var(--border)"}
                  strokeWidth={active ? 1.5 : 1}
                  opacity={selected && !active ? 0.25 : 0.8}
                  markerEnd="url(#rel-arrow)"
                />
              );
            })}

            {/* Nodes */}
            {placed.map((p) => {
              const isSel = selected === p.did;
              const dim = selected && !isSel && !neighbours.has(p.did);
              return (
                <g
                  key={p.did}
                  transform={`translate(${p.x}, ${p.y})`}
                  style={{ cursor: "pointer" }}
                  opacity={dim ? 0.35 : 1}
                  onClick={() => setSelected(isSel ? null : p.did)}
                >
                  <circle
                    r={isSel ? 9 : 6}
                    fill={isSel ? "var(--brand)" : "var(--brand-tint-strong)"}
                    stroke="var(--border-strong)"
                    strokeWidth={1}
                  />
                  <text
                    x={p.x > C ? 11 : -11}
                    y={4}
                    textAnchor={p.x > C ? "start" : "end"}
                    fontSize="10"
                    fill="var(--text-muted)"
                  >
                    {shorten(p.did, 8, 4)}
                  </text>
                </g>
              );
            })}
          </svg>

          <div style={{ flex: "1 1 220px", minWidth: 220 }}>
            <h3>
              {selected ? "Connections" : "Overview"}
            </h3>
            {!selected && (
              <p className="muted">
                {placed.length} member{placed.length === 1 ? "" : "s"} ·{" "}
                {edges.length} relationship{edges.length === 1 ? "" : "s"}.
                <br />
                Select a node to see its edges.
              </p>
            )}
            {selected && (
              <>
                <p>
                  <code className="truncate">{selected}</code>
                </p>
                {selectedEdges.length === 0 ? (
                  <p className="muted">No relationships.</p>
                ) : (
                  <ul style={{ paddingLeft: "1.1em", margin: 0 }}>
                    {selectedEdges.map((e) => (
                      <li key={e.id} style={{ marginBottom: 4 }}>
                        {e.issuerDid === selected ? (
                          <>
                            → vouched for{" "}
                            <code>{shorten(e.subjectDid, 8, 4)}</code>
                          </>
                        ) : (
                          <>
                            ← vouched for by{" "}
                            <code>{shorten(e.issuerDid, 8, 4)}</code>
                          </>
                        )}
                      </li>
                    ))}
                  </ul>
                )}
              </>
            )}
          </div>
        </section>
      )}
    </div>
  );
}