Add a bounded relation graph and knowledge journeys.
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
This commit is contained in:
parent
749752df4e
commit
b5b6f6bc00
15 changed files with 1290 additions and 52 deletions
234
ui/lib/graph.ts
Normal file
234
ui/lib/graph.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue