content-explorer/ui/lib/graph.ts

235 lines
6.7 KiB
TypeScript
Raw Normal View History

/**
* 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<Record<string, { forward?: string; inverse?: string }>>;
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<string, { other: string }[]>();
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<string, number>([[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);
},
};
}