CONTENT-WP-0004 keeps outgoing and incoming labels apart, limits the graph to two hops, and stores focus and detail in the URL. Assistant: grok Assistant-Session: 01a1531b-6698-7732-8587-f28ea604bcdf
234 lines
6.7 KiB
TypeScript
234 lines
6.7 KiB
TypeScript
/**
|
|
* 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);
|
|
},
|
|
};
|
|
}
|