/** * Neighborhood, incident relations, and reading order for the relation explorer. * Hop distance is capped at HOP_BOUND. Domain wording stays in the manifest. */ export const HOP_BOUND = 2; export type GraphNode = { id: string; title: string; }; export type GraphEdge = { id: string; predicate: string; from: string; to: string; }; export type GraphModel = { nodes: readonly GraphNode[]; edges: readonly GraphEdge[]; }; export type RelationLabels = Readonly>; export type IncidentRelation = { id: string; predicate: string; direction: "out" | "in"; wording: string; otherId: string; }; export type RelationBlocks = { outgoing: IncidentRelation[]; incoming: IncidentRelation[]; remainder: number; }; export type Neighborhood = { focusId: string; hops: 1 | 2; nodeIds: string[]; edges: GraphEdge[]; excludedCount: number; }; export type AdjacencyEdge = { id: string; predicate: string; from: string; to: string; direction: "out"; }; export type AdjacencyExport = { nodes: string[]; edges: AdjacencyEdge[]; }; export type GraphLocation = { focus: string; detail: string | null; hops: 1 | 2; }; export type GraphHistory = { read: () => string; push: (search: string) => void; subscribe: (listener: () => void) => () => void; }; function clampHops(hops: number): 1 | 2 { return hops <= 1 ? 1 : HOP_BOUND; } /** Undirected distance from the focus, capped at two hops. Edge direction is kept. */ export function selectNeighborhood(model: GraphModel, focusId: string, hops: number): Neighborhood { const maxHops = clampHops(hops); const known = new Set(model.nodes.map((node) => node.id)); if (!known.has(focusId)) { return { focusId, hops: maxHops, nodeIds: [], edges: [], excludedCount: model.edges.length }; } const links = new Map(); for (const node of model.nodes) links.set(node.id, []); for (const edge of model.edges) { if (!known.has(edge.from) || !known.has(edge.to)) continue; links.get(edge.from)?.push({ other: edge.to }); if (edge.to !== edge.from) links.get(edge.to)?.push({ other: edge.from }); } const distance = new Map([[focusId, 0]]); const queue = [focusId]; while (queue.length > 0) { const current = queue.shift(); if (current === undefined) break; const currentDistance = distance.get(current) ?? 0; if (currentDistance >= maxHops) continue; for (const link of links.get(current) ?? []) { if (distance.has(link.other)) continue; distance.set(link.other, currentDistance + 1); queue.push(link.other); } } const nodeIds = [...distance.keys()]; const inside = new Set(nodeIds); const edges = model.edges.filter((edge) => inside.has(edge.from) && inside.has(edge.to)); return { focusId, hops: maxHops, nodeIds, edges, excludedCount: model.edges.length - edges.length, }; } /** One record per in-scope edge. Parallel edges stay separate and keep their direction. */ export function exportAdjacency(neighborhood: Neighborhood): AdjacencyExport { return { nodes: [...neighborhood.nodeIds], edges: neighborhood.edges.map((edge) => ({ id: edge.id, predicate: edge.predicate, from: edge.from, to: edge.to, direction: "out", })), }; } function wordingFor(labels: RelationLabels, predicate: string, direction: "out" | "in"): string { const pair = labels[predicate]; if (direction === "out") return pair?.forward || predicate; return pair?.inverse || predicate; } /** * Place every relation that touches entityId in exactly one block. * A self-loop is outgoing. `limit` lists only the first incident relations * and reports the rest as `remainder`. */ export function partitionIncident( entityId: string, edges: readonly GraphEdge[], labels: RelationLabels, limit?: number, ): RelationBlocks { const incident: IncidentRelation[] = []; for (const edge of edges) { if (edge.from === entityId) { incident.push({ id: edge.id, predicate: edge.predicate, direction: "out", wording: wordingFor(labels, edge.predicate, "out"), otherId: edge.to, }); } else if (edge.to === entityId) { incident.push({ id: edge.id, predicate: edge.predicate, direction: "in", wording: wordingFor(labels, edge.predicate, "in"), otherId: edge.from, }); } } const capped = limit != null && limit < incident.length ? incident.slice(0, Math.max(0, limit)) : incident; const listed = new Set(capped.map((item) => item.id)); return { outgoing: capped.filter((item) => item.direction === "out"), incoming: capped.filter((item) => item.direction === "in"), remainder: incident.length - capped.length, }; } export type ResolvedStep = { id: string; title: string; href: string | null; }; /** Keep declared path order. An unknown id stays visible as its own id. */ export function readingPathCrumbs( path: readonly string[], resolve: (id: string) => { title: string; href: string | null } | null, ): ResolvedStep[] { return path.map((id) => { const found = resolve(id); return found ? { id, title: found.title, href: found.href } : { id, title: id, href: null }; }); } export function memberLinks( memberIds: readonly string[], resolve: (id: string) => { title: string; href: string | null } | null, ): ResolvedStep[] { return readingPathCrumbs(memberIds, resolve); } export function parseGraphSearch(search: string, knownIds: readonly string[], fallbackId: string): GraphLocation { const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); const known = new Set(knownIds); const requested = params.get("focus"); const detail = params.get("detail"); const hops = params.get("hops") === "1" ? 1 : HOP_BOUND; return { focus: requested && known.has(requested) ? requested : fallbackId, detail: detail && known.has(detail) ? detail : null, hops, }; } export function serializeGraphSearch(location: GraphLocation): string { const params = new URLSearchParams(); params.set("focus", location.focus); if (location.detail) params.set("detail", location.detail); if (location.hops === 1) params.set("hops", "1"); return params.toString(); } export function browserGraphHistory(): GraphHistory { return { read: () => (typeof window === "undefined" ? "" : window.location.search), push: (search) => { const path = window.location.pathname; window.history.pushState(null, "", search ? `${path}?${search}` : path); }, subscribe: (listener) => { window.addEventListener("popstate", listener); return () => window.removeEventListener("popstate", listener); }, }; }