From 14cf3e052d139860cf604cd76c82cb3b2969d5cb Mon Sep 17 00:00:00 2001 From: Maxime Van Hees Date: Fri, 14 Nov 2025 22:01:49 +0100 Subject: [PATCH] MVP3: added dotted lines to show who connected to who --- src/app/api/graph/route.ts | 57 ++++++++++++++++++++++++++++++++------ src/components/Graph.tsx | 23 +++++++++++++++ src/lib/validators.ts | 5 +++- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/app/api/graph/route.ts b/src/app/api/graph/route.ts index b85d653..7b835cf 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) => ({ @@ -19,21 +20,59 @@ export async function GET() { role: p.role, })); - const edges = connections.map((c: any) => { - const introducedByNames: string[] = Array.isArray(c.introducedByChain) - ? c.introducedByChain.map((pid: string) => nameById.get(pid)).filter(Boolean) - : []; - return { + const edges: any[] = []; + + for (const c of connections as any[]) { + const chain: string[] = Array.isArray(c.introducedByChain) ? c.introducedByChain : []; + const introducedByNames: string[] = chain.map((pid: string) => nameById.get(pid)).filter(Boolean) as string[]; + const hasProv = chain.length > 0; + + if (!hasProv) { + // Plain direct edge (no introducer) + edges.push({ + id: c.id, + source: c.personAId, + target: c.personBId, + introducedByCount: 0, + hasProvenance: false, + introducedByNames: [], + eventLabels: Array.isArray(c.eventLabels) ? c.eventLabels : [], + notes: c.notes ?? null, + }); + continue; + } + + // 1) Original A—C edge rendered as dotted "indirect" line + edges.push({ id: c.id, source: c.personAId, target: c.personBId, - introducedByCount: c.introducedByChain.length, - hasProvenance: c.introducedByChain.length > 0, + introducedByCount: chain.length, + hasProvenance: true, introducedByNames, eventLabels: Array.isArray(c.eventLabels) ? c.eventLabels : [], notes: c.notes ?? null, - }; - }); + indirect: true, + }); + + // 2) Virtual path edges along introducer chain: A—B, B—…—C (solid) + const path = [c.personAId, ...chain, c.personBId]; + for (let i = 0; i < path.length - 1; i++) { + const source = path[i]; + const target = path[i + 1]; + edges.push({ + id: `virtual:${c.id}:${i}:${source}:${target}`, + source, + target, + introducedByCount: chain.length, + hasProvenance: true, + introducedByNames, + eventLabels: Array.isArray(c.eventLabels) ? c.eventLabels : [], + notes: c.notes ?? null, + virtual: true, + }); + } + } return NextResponse.json({ nodes, edges }, { status: 200 }); } catch (err) { diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index c2b00e7..57bc2c5 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -54,6 +54,10 @@ export default function Graph({ data, height = 600 }: Props) { e.introducedByNames && e.introducedByNames.length ? `Introduced by: ${e.introducedByNames.join(" → ")}` : ""; + // Only set flags when true so the style selector [indirect] matches by presence. + const flags: Record = {}; + if ((e as any).indirect) flags.indirect = "1"; + if ((e as any).virtual) flags.virtual = "1"; return { data: { id: e.id, @@ -65,6 +69,7 @@ export default function Graph({ data, height = 600 }: Props) { eventLabels: e.eventLabels ?? [], notes: e.notes ?? null, edgeLabel: label, + ...flags, }, }; }); @@ -117,6 +122,24 @@ export default function Graph({ data, height = 600 }: Props) { "line-color": "#16a34a", // green-600 }, }, + // Dotted line for indirect A—C edge when there is an introducer + { + selector: "edge[indirect], edge[introducedByCount > 0][!virtual]", + style: { + // Make indirect A—C edge or any non-virtual edge with provenance visibly different + "line-style": "dashed", + "line-color": "#64748b", // slate-500 for contrast + width: 2, + opacity: 0.9, + }, + }, + // Keep virtual path edges solid (default) + { + selector: "edge[virtual]", + style: { + "line-style": "solid", + }, + }, { selector: "edge:selected", style: { diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 2fc1d68..b682bdb 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -47,7 +47,7 @@ export const graphNodeSchema = z.object({ }); export const graphEdgeSchema = z.object({ - id: z.string().uuid(), + id: z.string(), source: z.string().uuid(), target: z.string().uuid(), introducedByCount: z.number().int().nonnegative(), @@ -55,6 +55,9 @@ export const graphEdgeSchema = z.object({ introducedByNames: z.array(z.string()).default([]).optional(), eventLabels: z.array(z.string()).default([]).optional(), notes: z.string().nullable().optional(), + // Visualization flags + indirect: z.boolean().optional(), // true for the original A—C edge when an introducer exists (dotted) + virtual: z.boolean().optional(), // true for virtual path edges (A—B, B—C, ...) }); export const graphResponseSchema = z.object({