diff --git a/src/app/api/graph/route.ts b/src/app/api/graph/route.ts index 4dcaa62..b85d653 100644 --- a/src/app/api/graph/route.ts +++ b/src/app/api/graph/route.ts @@ -9,6 +9,7 @@ export async function GET() { prisma.person.findMany(), prisma.connection.findMany(), ]); + const nameById = new Map(people.map((p: any) => [p.id, p.name])); const nodes = people.map((p: any) => ({ id: p.id, @@ -18,13 +19,21 @@ export async function GET() { role: p.role, })); - const edges = connections.map((c: any) => ({ - id: c.id, - source: c.personAId, - target: c.personBId, - introducedByCount: c.introducedByChain.length, - hasProvenance: c.introducedByChain.length > 0, - })); + const edges = connections.map((c: any) => { + const introducedByNames: string[] = Array.isArray(c.introducedByChain) + ? c.introducedByChain.map((pid: string) => nameById.get(pid)).filter(Boolean) + : []; + return { + id: c.id, + source: c.personAId, + target: c.personBId, + introducedByCount: c.introducedByChain.length, + hasProvenance: c.introducedByChain.length > 0, + introducedByNames, + eventLabels: Array.isArray(c.eventLabels) ? c.eventLabels : [], + notes: c.notes ?? null, + }; + }); return NextResponse.json({ nodes, edges }, { status: 200 }); } catch (err) { diff --git a/src/components/ConnectionForm.tsx b/src/components/ConnectionForm.tsx index de57c26..9c5df0d 100644 --- a/src/components/ConnectionForm.tsx +++ b/src/components/ConnectionForm.tsx @@ -65,10 +65,23 @@ export default function ConnectionForm() { return; } + // Build introduced-by chain preserving order. If the user selected an introducer + // but forgot to click “Add”, include it automatically on save (last position). + const baseChain = introducedBy.filter(Boolean).map((p) => (p as Selected)!.id); + let chain = [...baseChain]; + if ( + introducerPick && + introducerPick.id !== personA.id && + introducerPick.id !== personB.id && + !chain.includes(introducerPick.id) + ) { + chain.push(introducerPick.id); + } + const payload = { personAId: personA.id, personBId: personB.id, - introducedByChain: introducedBy.filter(Boolean).map((p) => (p as Selected)!.id), + introducedByChain: chain, eventLabels: splitCsv(eventLabelsText), notes: notes.trim() || undefined, }; diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index 3cc76a3..c2b00e7 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -17,6 +17,9 @@ type GraphEdgeDTO = { target: string; introducedByCount: number; hasProvenance: boolean; + introducedByNames?: string[]; + eventLabels?: string[]; + notes?: string | null; }; type GraphData = { @@ -46,15 +49,25 @@ export default function Graph({ data, height = 600 }: Props) { sectors: n.sectors, }, })); - const edges = data.edges.map((e) => ({ - data: { - id: e.id, - source: e.source, - target: e.target, - introducedByCount: e.introducedByCount, - hasProvenance: e.hasProvenance, - }, - })); + const edges = data.edges.map((e) => { + const label = + e.introducedByNames && e.introducedByNames.length + ? `Introduced by: ${e.introducedByNames.join(" → ")}` + : ""; + return { + data: { + id: e.id, + source: e.source, + target: e.target, + introducedByCount: e.introducedByCount, + hasProvenance: e.hasProvenance, + introducedByNames: e.introducedByNames ?? [], + eventLabels: e.eventLabels ?? [], + notes: e.notes ?? null, + edgeLabel: label, + }, + }; + }); return { nodes, edges }; }, [data]); @@ -104,6 +117,23 @@ export default function Graph({ data, height = 600 }: Props) { "line-color": "#16a34a", // green-600 }, }, + { + selector: "edge:selected", + style: { + label: "data(edgeLabel)", + "font-size": 10, + color: "#111827", + "text-background-color": "#ffffff", + "text-background-opacity": 1, + "text-background-padding": "2px", + "text-border-color": "#e5e7eb", + "text-border-width": 1, + "text-border-opacity": 1, + "text-wrap": "wrap", + "text-max-width": "120px", + "text-rotation": "autorotate", + }, + }, { selector: ":selected", style: { @@ -147,6 +177,9 @@ export default function Graph({ data, height = 600 }: Props) { target: d.target, introducedByCount: Number(d.introducedByCount ?? 0), hasProvenance: Boolean(d.hasProvenance), + introducedByNames: Array.isArray(d.introducedByNames) ? (d.introducedByNames as string[]) : [], + eventLabels: Array.isArray(d.eventLabels) ? (d.eventLabels as string[]) : [], + notes: (d.notes ?? null) as string | null, }); }; @@ -249,9 +282,22 @@ export default function Graph({ data, height = 600 }: Props) { {selectedEdge.introducedByCount}
- Has provenance:{" "} - {selectedEdge.hasProvenance ? "Yes" : "No"} + Introduced by:{" "} + {selectedEdge.introducedByNames && selectedEdge.introducedByNames.length > 0 + ? selectedEdge.introducedByNames.join(" → ") + : "None"}
+ {selectedEdge.eventLabels && selectedEdge.eventLabels.length > 0 && ( +
+ Event labels:{" "} + {selectedEdge.eventLabels.join(", ")} +
+ )} + {selectedEdge.notes ? ( +
+ Notes: {selectedEdge.notes} +
+ ) : null} )} diff --git a/src/lib/validators.ts b/src/lib/validators.ts index b65b505..2fc1d68 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -52,6 +52,9 @@ export const graphEdgeSchema = z.object({ target: z.string().uuid(), introducedByCount: z.number().int().nonnegative(), hasProvenance: z.boolean(), + introducedByNames: z.array(z.string()).default([]).optional(), + eventLabels: z.array(z.string()).default([]).optional(), + notes: z.string().nullable().optional(), }); export const graphResponseSchema = z.object({