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:
tegwick 2026-10-19 10:00:20 +02:00
parent 749752df4e
commit b5b6f6bc00
15 changed files with 1290 additions and 52 deletions

View file

@ -52,7 +52,29 @@
"asOf": "2026-10-12",
"claimRefs": [
"claim:feedback:tldr"
]
],
"properties": {
"glossary": "A loop that returns an action's result.",
"preview": {
"title": "Feedback",
"summary": "The result of an action changes the next action."
},
"collection": {
"title": "Ways to draw feedback",
"members": [
"urn:ce:systems-thinking:method:causal-loop",
"urn:ce:systems-thinking:method:stock-flow"
]
},
"related": [
"urn:ce:systems-thinking:concept:emergence"
],
"readingPath": [
"urn:ce:systems-thinking:concept:system-boundary",
"urn:ce:systems-thinking:concept:feedback",
"urn:ce:systems-thinking:method:causal-loop"
]
}
},
{
"id": "urn:ce:systems-thinking:concept:emergence",
@ -171,7 +193,14 @@
"asOf": "2026-10-12",
"claimRefs": [
"claim:causal-loop:tldr"
]
],
"properties": {
"glossary": "Arrows that show what influences what.",
"preview": {
"title": "Causal loop diagram",
"summary": "Influence arrows marked as reinforcing or balancing."
}
}
},
{
"id": "urn:ce:systems-thinking:method:stock-flow",

View file

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { GraphExplorer } from "@/components/GraphExplorer";
import { findGraph } from "@/lib/catalog";
function first(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
export async function generateMetadata({ params }: { params: Promise<{ dataspace: string }> }): Promise<Metadata> {
const { dataspace } = await params;
const model = findGraph(dataspace);
if (!model) return { title: "Content Explorer" };
return { title: `Graph · ${model.dataspaceTitle} · Content Explorer` };
}
export default async function GraphPage({
params,
searchParams,
}: {
params: Promise<{ dataspace: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { dataspace } = await params;
const query = await searchParams;
const model = findGraph(dataspace);
if (!model) notFound();
const initial = new URLSearchParams();
const focus = first(query.focus);
const detail = first(query.detail);
const hops = first(query.hops);
if (focus) initial.set("focus", focus);
if (detail) initial.set("detail", detail);
if (hops) initial.set("hops", hops);
const initialSearch = initial.size > 0 ? `?${initial.toString()}` : "";
return <GraphExplorer model={model} initialSearch={initialSearch} />;
}

View file

@ -1,9 +1,15 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { findEntity, findKind } from "@/lib/catalog";
import { findEntity, findGraph, findKind, type EntityView } from "@/lib/catalog";
import { partitionIncident } from "@/lib/graph";
import { EntityDetail } from "./EntityDetail";
import { KindIndex } from "./KindIndex";
const systemsRoot = join(process.cwd(), "../examples/dataspaces/systems-thinking");
describe("kind and detail views", () => {
it("renders the registry plural and a linked record", () => {
const kind = findKind("software-architecture", "concept");
@ -24,5 +30,82 @@ describe("kind and detail views", () => {
expect(html).toContain("formal-measure");
expect(html).toContain("unknown");
expect(html).toContain("Stock and flow model");
expect(html).not.toContain("glossary-tip");
expect(html).not.toContain("link-preview");
expect(html).toContain('href="/systems-thinking/method/stock-flow/"');
});
it("renders both relation blocks, journeys, and optional hints for feedback", () => {
const snapshot = JSON.parse(readFileSync(join(systemsRoot, "snapshot.json"), "utf8")) as {
entities: { id: string; properties?: { glossary?: string; preview?: { title: string; summary: string }; readingPath?: string[] } }[];
relations: { id: string; from: string; to: string }[];
};
const manifest = JSON.parse(readFileSync(join(systemsRoot, "manifest.json"), "utf8")) as {
relationLabels: Record<string, { forward: string; inverse: string }>;
};
const entity = findEntity("systems-thinking", "concept", "feedback");
expect(entity).not.toBeNull();
const html = renderToStaticMarkup(<EntityDetail entity={entity!} />);
const host = documentFrom(html);
const incident = snapshot.relations.filter((relation) => relation.from === entity!.id || relation.to === entity!.id);
const rendered = [...host.querySelectorAll("[data-relation]")].map((node) => node.getAttribute("data-relation"));
expect(rendered.sort()).toEqual(incident.map((relation) => relation.id).sort());
expect(host.querySelector('[aria-labelledby="outgoing-relations"] .text-muted')?.textContent).toBe(
manifest.relationLabels["represented-by"].forward,
);
const incomingWordings = [...host.querySelectorAll('[aria-labelledby="incoming-relations"] .text-muted')].map(
(node) => node.textContent,
);
expect(incomingWordings).toContain(manifest.relationLabels.models.inverse);
expect(incomingWordings).not.toContain(manifest.relationLabels.models.forward);
const causal = host.querySelector('[data-relation="r1"]');
const causalEntity = snapshot.entities.find((item) => item.id.endsWith(":causal-loop"));
expect(causal?.querySelector(".glossary-tip")?.textContent).toBe(causalEntity?.properties?.glossary);
expect(causal?.querySelector(".preview-title")?.textContent).toBe(causalEntity?.properties?.preview?.title);
expect(causal?.querySelector(".preview-summary")?.textContent).toBe(causalEntity?.properties?.preview?.summary);
expect(causal?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/causal-loop/");
const modeled = host.querySelector('[data-relation="r6"]');
expect(modeled?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/stock-flow/");
expect(modeled?.querySelector(".glossary-tip")).toBeNull();
expect(modeled?.querySelector(".link-preview")).toBeNull();
const members = [...host.querySelectorAll("#collection-heading")];
expect(members.length).toBe(1);
const collection = members[0]?.parentElement;
expect(collection?.querySelector('a[href="/systems-thinking/method/causal-loop/"]')).toBeTruthy();
expect(collection?.querySelector('a[href="/systems-thinking/method/stock-flow/"]')).toBeTruthy();
expect(host.querySelector('[aria-labelledby="related-heading"] a')?.getAttribute("href")).toBe(
"/systems-thinking/concept/emergence/",
);
const crumbs = [...host.querySelectorAll('[aria-label="Reading path"] li')];
const declared = snapshot.entities.find((item) => item.id === entity!.id)?.properties?.readingPath ?? [];
expect(crumbs).toHaveLength(declared.length);
expect(crumbs[0]?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/concept/system-boundary/");
expect(crumbs[1]?.querySelector("[aria-current='page']")?.textContent).toBe(entity!.title);
expect(crumbs[2]?.querySelector("a")?.getAttribute("href")).toBe("/systems-thinking/method/causal-loop/");
});
it("shows a remainder when the shipped partition leaves relations out", () => {
const entity = findEntity("systems-thinking", "concept", "feedback");
const graph = findGraph("systems-thinking");
expect(entity && graph).toBeTruthy();
const blocks = partitionIncident(entity!.id, graph!.edges, graph!.labels, 1);
const listed = new Set([...blocks.outgoing, ...blocks.incoming].map((relation) => relation.id));
const view: EntityView = {
...entity!,
outgoing: entity!.outgoing.filter((relation) => listed.has(relation.id)),
incoming: entity!.incoming.filter((relation) => listed.has(relation.id)),
relationRemainder: blocks.remainder,
};
const html = renderToStaticMarkup(<EntityDetail entity={view} />);
expect(blocks.remainder).toBeGreaterThan(0);
expect(html).toContain(`${blocks.remainder} more relations are not listed.`);
const host = documentFrom(html);
expect([...host.querySelectorAll("[data-relation]")].map((node) => node.getAttribute("data-relation")).sort()).toEqual(
[...listed].sort(),
);
});
});
function documentFrom(html: string): Document {
return new DOMParser().parseFromString(html, "text/html");
}

View file

@ -1,15 +1,52 @@
import { PageHeader } from "@/components/PageHeader";
import type { EntityView } from "@/lib/catalog";
import { RecordLink } from "@/components/RecordLink";
import type { EntityView, RelationItem } from "@/lib/catalog";
/**
* Generic record page: title, summary, sources, claims, and neighbouring relations.
* OnCo EntityDetail was not copied. That file special-cases domain panels.
*/
function RelationBlock({
headingId,
title,
relations,
}: {
headingId: string;
title: string;
relations: RelationItem[];
}) {
return (
<section aria-labelledby={headingId}>
<h2 id={headingId} className="mb-3 text-lg">{title}</h2>
{relations.length === 0 ? (
<p className="text-sm text-muted">No {title.toLowerCase()} relations.</p>
) : (
<ul className="space-y-2 text-sm">
{relations.map((relation) => (
<li key={relation.id} data-relation={relation.id} data-direction={relation.direction}>
<span className="text-muted">{relation.wording}</span>
{" · "}
<RecordLink link={relation.other} />
</li>
))}
</ul>
)}
</section>
);
}
export function EntityDetail({ entity }: { entity: EntityView }) {
const currentIndex = entity.readingPath.findIndex((step) => step.id === entity.id);
return (
<>
<PageHeader kicker={entity.kindLabel} title={entity.title} lede={entity.tldr} />
<div className="mx-auto max-w-7xl space-y-10 px-4 py-10 sm:px-6">
{entity.glossary ? (
<p className="glossary" data-glossary={entity.glossary}>
<span className="glossary-tip" role="tooltip">{entity.glossary}</span>
</p>
) : null}
<p className="max-w-3xl text-body leading-relaxed">{entity.summary}</p>
{entity.tags.length > 0 ? (
<ul className="flex flex-wrap gap-2">
@ -18,6 +55,28 @@ export function EntityDetail({ entity }: { entity: EntityView }) {
))}
</ul>
) : null}
{entity.readingPath.length > 0 ? (
<nav aria-label="Reading path">
<ol className="flex flex-wrap gap-2 text-sm">
{entity.readingPath.map((step, index) => {
const current = step.id === entity.id;
const earlier = currentIndex >= 0 && index < currentIndex;
return (
<li key={`${step.id}:${index}`}>
{index > 0 ? <span className="text-muted"> / </span> : null}
{current ? (
<span aria-current="page">{step.title}</span>
) : earlier || step.href ? (
<a className="underline" href={step.href ?? "#"}>{step.title}</a>
) : (
<span>{step.title}</span>
)}
</li>
);
})}
</ol>
</nav>
) : null}
<section aria-labelledby="sources-heading">
<h2 id="sources-heading" className="mb-3 text-lg">Sources</h2>
{entity.sources.length === 0 ? (
@ -49,27 +108,47 @@ export function EntityDetail({ entity }: { entity: EntityView }) {
</ul>
)}
</section>
<section aria-labelledby="relations-heading">
<h2 id="relations-heading" className="mb-3 text-lg">Relations</h2>
{entity.relations.length === 0 ? (
<p className="text-sm text-muted">No relations.</p>
) : (
<section aria-labelledby="relations-heading" className="space-y-6">
<h2 id="relations-heading" className="text-lg">Relations</h2>
<RelationBlock headingId="outgoing-relations" title="Outgoing" relations={entity.outgoing} />
<RelationBlock headingId="incoming-relations" title="Incoming" relations={entity.incoming} />
{entity.relationRemainder > 0 ? (
<p data-relation-remainder={entity.relationRemainder}>
{entity.relationRemainder} more relations are not listed.
</p>
) : null}
</section>
{entity.collection ? (
<section aria-labelledby="collection-heading">
<h2 id="collection-heading" className="mb-3 text-lg">{entity.collection.title}</h2>
{entity.collection.members.length === 0 ? (
<p className="text-sm text-muted">No members.</p>
) : (
<ul className="space-y-2 text-sm">
{entity.collection.members.map((member) => (
<li key={member.id}>
<RecordLink link={member} />
</li>
))}
</ul>
)}
</section>
) : null}
{entity.related.length > 0 ? (
<section aria-labelledby="related-heading">
<h2 id="related-heading" className="mb-3 text-lg">Related</h2>
<ul className="space-y-2 text-sm">
{entity.relations.map((relation) => (
<li key={`${relation.direction}:${relation.wording}:${relation.otherTitle}`}>
<span className="text-muted">{relation.wording}</span>
{" · "}
{relation.otherHref ? (
<a className="underline" href={relation.otherHref}>{relation.otherTitle}</a>
) : (
relation.otherTitle
)}
{entity.related.map((item) => (
<li key={item.id}>
<RecordLink link={item} />
</li>
))}
</ul>
)}
</section>
</section>
) : null}
<p className="text-sm">
<a className="underline" href={entity.graphHref}>Open graph</a>
{" · "}
<a className="underline" href={entity.kindHref}>Back to {entity.kindLabel}</a>
</p>
</div>

View file

@ -0,0 +1,177 @@
// @vitest-environment happy-dom
import { createRoot, type Root } from "react-dom/client";
import { act } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { findGraph } from "@/lib/catalog";
import { browserGraphHistory, type GraphHistory } from "@/lib/graph";
import { GraphExplorer } from "./GraphExplorer";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});
function sessionHistory(): GraphHistory & { back: () => void; forward: () => void; location: () => string } {
const stack = [""];
let index = 0;
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) listener();
};
return {
read: () => (stack[index] ? `?${stack[index]}` : ""),
push: (search) => {
stack.splice(index + 1);
stack.push(search);
index = stack.length - 1;
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
back: () => {
if (index === 0) return;
index -= 1;
notify();
},
forward: () => {
if (index >= stack.length - 1) return;
index += 1;
notify();
},
location: () => (stack[index] ? `?${stack[index]}` : ""),
};
}
function focusTitle(): string {
return document.querySelector("[data-focus]")?.textContent ?? "";
}
function neighborhoodIds(): string[] {
return [...document.querySelectorAll("[data-neighborhood]")].map((node) => node.getAttribute("data-neighborhood") ?? "");
}
async function renderGraph(history: GraphHistory, initialSearch = "") {
const model = findGraph("systems-thinking");
if (!model) throw new Error("missing graph");
const host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
await act(async () => {
root?.render(<GraphExplorer model={model} initialSearch={initialSearch} history={history} />);
});
return model;
}
describe("graph explorer history", () => {
it("changes the visible neighborhood with focus and hop distance", async () => {
const history = sessionHistory();
const model = await renderGraph(history);
const feedback = model.nodes.find((node) => node.id.endsWith(":feedback"));
const causal = model.nodes.find((node) => node.id.endsWith(":causal-loop"));
const boundary = model.nodes.find((node) => node.id.endsWith(":system-boundary"));
if (!feedback || !causal || !boundary) throw new Error("missing fixture nodes");
const wide = neighborhoodIds();
expect(wide).toContain(feedback.id);
expect(wide).toContain(boundary.id);
const excluded = Number(document.querySelector("[data-excluded-count]")?.getAttribute("data-excluded-count"));
expect(excluded).toBeGreaterThan(0);
expect(document.body.textContent).toContain(`${excluded} relations outside this neighborhood`);
await act(async () => {
document.querySelector("button[aria-pressed='false']")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(neighborhoodIds()).not.toContain(boundary.id);
expect(Number(document.querySelector("[data-excluded-count]")?.getAttribute("data-excluded-count"))).toBeGreaterThan(excluded);
await act(async () => {
document.querySelector(`button[data-address="${causal.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(focusTitle()).toBe(causal.title);
expect(neighborhoodIds()).toContain(causal.id);
expect(neighborhoodIds()).not.toEqual(wide);
const adjacency = document.querySelector("[data-adjacency]")?.textContent ?? "";
const exported = JSON.parse(adjacency) as { edges: { id: string; predicate: string; direction: string }[] };
expect(exported.edges.length).toBeGreaterThan(0);
expect(exported.edges.every((edge) => edge.id && edge.predicate && edge.direction === "out")).toBe(true);
});
it("restores focus and detail on reload, back, and forward", async () => {
const history = sessionHistory();
const model = await renderGraph(history);
const feedback = model.nodes.find((node) => node.id.endsWith(":feedback"));
const causal = model.nodes.find((node) => node.id.endsWith(":causal-loop"));
const boundary = model.nodes.find((node) => node.id.endsWith(":system-boundary"));
if (!feedback || !causal || !boundary) throw new Error("missing fixture nodes");
expect(focusTitle()).toBe(feedback.title);
expect(document.body.textContent).toContain("Represented by");
expect(document.body.textContent).toContain("Modeled by");
await act(async () => {
document.querySelector(`button[data-focus-node="${causal.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(focusTitle()).toBe(causal.title);
expect(decodeURIComponent(history.location())).toContain(causal.id);
await act(async () => {
document.querySelector(`button[data-open-detail="${feedback.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.querySelector("[data-detail]")?.getAttribute("data-detail")).toBe(feedback.id);
expect(focusTitle()).toBe(causal.title);
const saved = history.location();
expect(decodeURIComponent(saved)).toContain("detail=");
expect(decodeURIComponent(saved)).toContain(causal.id);
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
await renderGraph(history, saved);
expect(focusTitle()).toBe(causal.title);
expect(document.querySelector("[data-detail]")?.getAttribute("data-detail")).toBe(feedback.id);
await act(async () => {
history.back();
});
expect(focusTitle()).toBe(causal.title);
expect(document.querySelector("[data-detail]")).toBeNull();
await act(async () => {
history.back();
});
expect(focusTitle()).toBe(feedback.title);
expect(neighborhoodIds()).toContain(boundary.id);
await act(async () => {
history.forward();
});
expect(focusTitle()).toBe(causal.title);
expect(document.querySelector("[data-detail]")).toBeNull();
});
it("reads a pushed query and a popstate traversal", () => {
const model = findGraph("systems-thinking");
if (!model) throw new Error("missing graph");
window.history.replaceState(null, "", "/systems-thinking/graph/");
const history = browserGraphHistory();
const seen: string[] = [];
const stop = history.subscribe(() => seen.push(history.read()));
const focus = model.defaultFocusId;
history.push(`focus=${encodeURIComponent(focus)}&detail=${encodeURIComponent(focus)}`);
expect(window.location.search).toContain("focus=");
expect(window.location.search).toContain("detail=");
expect(seen).toEqual([]);
window.history.pushState(null, "", `/systems-thinking/graph/?focus=${encodeURIComponent(focus)}`);
window.dispatchEvent(new PopStateEvent("popstate"));
expect(seen).toEqual([`?focus=${encodeURIComponent(focus)}`]);
stop();
});
});

View file

@ -0,0 +1,143 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { GraphDataset } from "@/lib/catalog";
import {
browserGraphHistory,
exportAdjacency,
parseGraphSearch,
partitionIncident,
selectNeighborhood,
serializeGraphSearch,
type GraphHistory,
type GraphLocation,
type GraphModel,
} from "@/lib/graph";
export function GraphExplorer({
model,
initialSearch = "",
history,
}: {
model: GraphDataset;
initialSearch?: string;
history?: GraphHistory;
}) {
const viewHistory = useMemo(() => history ?? browserGraphHistory(), [history]);
const [search, setSearch] = useState(initialSearch);
const knownIds = useMemo(() => model.nodes.map((node) => node.id), [model.nodes]);
const graph: GraphModel = useMemo(
() => ({
nodes: model.nodes.map((node) => ({ id: node.id, title: node.title })),
edges: model.edges,
}),
[model.edges, model.nodes],
);
const byId = useMemo(() => new Map(model.nodes.map((node) => [node.id, node])), [model.nodes]);
const location = parseGraphSearch(search, knownIds, model.defaultFocusId);
const neighborhood = selectNeighborhood(graph, location.focus, location.hops);
const blocks = partitionIncident(location.focus, model.edges, model.labels);
const adjacency = exportAdjacency(neighborhood);
const focus = byId.get(location.focus);
const detail = location.detail ? byId.get(location.detail) : undefined;
useEffect(() => {
const read = () => setSearch(viewHistory.read());
read();
return viewHistory.subscribe(read);
}, [viewHistory]);
function commit(next: GraphLocation) {
const query = serializeGraphSearch(next);
viewHistory.push(query);
setSearch(query ? `?${query}` : "");
}
return (
<div className="mx-auto max-w-7xl space-y-8 px-4 py-10 sm:px-6">
<header>
<p className="kicker mb-2">{model.dataspaceTitle}</p>
<h1 className="text-3xl font-semibold tracking-tight">Graph</h1>
<p className="mt-3 text-sm text-muted">Focus: <span data-focus={location.focus}>{focus?.title ?? location.focus}</span></p>
</header>
<div className="flex flex-wrap gap-2">
<button type="button" aria-pressed={location.hops === 1} onClick={() => commit({ ...location, hops: 1 })}>1 hop</button>
<button type="button" aria-pressed={location.hops === 2} onClick={() => commit({ ...location, hops: 2 })}>2 hops</button>
</div>
<p data-excluded-count={neighborhood.excludedCount}>
{neighborhood.excludedCount} relations outside this neighborhood
</p>
<section aria-label="Neighborhood">
<h2 className="mb-3 text-lg">Neighborhood</h2>
<ul className="space-y-2 text-sm">
{neighborhood.nodeIds.map((id) => {
const node = byId.get(id);
return (
<li key={id} data-neighborhood={id}>
<button
type="button"
className="mr-2 underline"
data-focus-node={id}
aria-current={id === location.focus ? "true" : undefined}
onClick={() => commit({ ...location, focus: id })}
>
{node?.title ?? id}
</button>
<button type="button" data-open-detail={id} onClick={() => commit({ ...location, detail: id })}>
Open
</button>
</li>
);
})}
</ul>
</section>
<section aria-label="Records">
<h2 className="mb-3 text-lg">Records</h2>
<ul className="flex flex-wrap gap-2 text-sm">
{model.nodes.map((node) => (
<li key={node.id}>
<button type="button" className="underline" data-address={node.id} onClick={() => commit({ ...location, focus: node.id })}>
{node.title}
</button>
</li>
))}
</ul>
</section>
<section aria-label="Outgoing">
<h2 className="mb-3 text-lg">Outgoing</h2>
{blocks.outgoing.length === 0 ? <p className="text-sm text-muted">No outgoing relations.</p> : (
<ul className="space-y-1 text-sm">
{blocks.outgoing.map((relation) => (
<li key={relation.id} data-relation={relation.id}>{relation.wording}</li>
))}
</ul>
)}
</section>
<section aria-label="Incoming">
<h2 className="mb-3 text-lg">Incoming</h2>
{blocks.incoming.length === 0 ? <p className="text-sm text-muted">No incoming relations.</p> : (
<ul className="space-y-1 text-sm">
{blocks.incoming.map((relation) => (
<li key={relation.id} data-relation={relation.id}>{relation.wording}</li>
))}
</ul>
)}
</section>
{blocks.remainder > 0 ? (
<p>{blocks.remainder} more relations are not listed.</p>
) : null}
{detail ? (
<article aria-label="Opened detail" data-detail={detail.id}>
<h2 className="text-lg">{detail.title}</h2>
<p>{detail.summary}</p>
<a className="underline" href={detail.href}>Open record</a>
<button type="button" onClick={() => commit({ ...location, detail: null })}>Close detail</button>
</article>
) : null}
<section aria-label="Adjacency">
<h2 className="mb-3 text-lg">Adjacency</h2>
<pre data-adjacency>{JSON.stringify(adjacency)}</pre>
</section>
</div>
);
}

View file

@ -0,0 +1,28 @@
import type { EntityLink } from "@/lib/catalog";
/** A record link. Glossary and preview render only when that optional data is present. */
export function RecordLink({ link }: { link: EntityLink }) {
const anchor = link.href ? (
<a className="underline" href={link.href}>{link.title}</a>
) : (
<span>{link.title}</span>
);
return (
<span className="record-link">
{link.glossary ? (
<span className="glossary" data-glossary={link.glossary}>
{anchor}
<span className="glossary-tip" role="tooltip">{link.glossary}</span>
</span>
) : (
anchor
)}
{link.preview ? (
<span className="link-preview">
<span className="preview-title">{link.preview.title}</span>
<span className="preview-summary">{link.preview.summary}</span>
</span>
) : null}
</span>
);
}

View file

@ -22,6 +22,7 @@ export function SiteHeader({ dataspaces }: { dataspaces: DataspaceNav[] }) {
{active ? (
<nav aria-label="Primary" className="flex min-w-0 items-center gap-4 overflow-x-auto text-sm">
<a href={`/${active.id}/`} className="shrink-0 text-foreground">{active.title}</a>
<a href={`/${active.id}/graph/`} className="shrink-0 text-muted hover:text-foreground">Graph</a>
{active.kinds.map((kind) => (
<a key={kind.id} href={kind.href} className="shrink-0 text-muted hover:text-foreground">
{kind.plural}

View file

@ -0,0 +1,24 @@
{
"nodes": [
{"id": "n0", "title": "Node 0"},
{"id": "n1", "title": "Node 1"},
{"id": "n2", "title": "Node 2"},
{"id": "n3", "title": "Node 3"},
{"id": "n4", "title": "Node 4"},
{"id": "n5", "title": "Node 5"},
{"id": "n6", "title": "Node 6"},
{"id": "n7", "title": "Node 7"},
{"id": "n8", "title": "Node 8"},
{"id": "solo", "title": "Solo"}
],
"edges": [
{"id": "e0", "predicate": "links", "from": "n0", "to": "n1"},
{"id": "e1", "predicate": "links", "from": "n1", "to": "n2"},
{"id": "e2", "predicate": "links", "from": "n2", "to": "n3"},
{"id": "e3", "predicate": "links", "from": "n3", "to": "n4"},
{"id": "e4", "predicate": "links", "from": "n4", "to": "n5"},
{"id": "e5", "predicate": "links", "from": "n5", "to": "n6"},
{"id": "e6", "predicate": "links", "from": "n6", "to": "n7"},
{"id": "e7", "predicate": "links", "from": "n7", "to": "n8"}
]
}

View file

@ -1,6 +1,7 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { columnDefs, HOME_COLUMNS, mapBrowserRow, type BrowserRow, type ColumnDef } from "./browser-row";
import { memberLinks, partitionIncident, readingPathCrumbs, type GraphEdge, type RelationLabels } from "./graph";
type KindRecord = { id: string; label: string; plural: string };
@ -13,6 +14,7 @@ type EntityRecord = {
tags?: string[];
sourceRefs?: string[];
claimRefs?: string[];
properties?: unknown;
};
type SourceRecord = { id: string; title: string; license: string };
@ -69,6 +71,22 @@ export type RelationView = {
otherHref: string | null;
};
export type EntityLink = {
id: string;
title: string;
href: string | null;
glossary: string | null;
preview: { title: string; summary: string } | null;
};
export type RelationItem = {
id: string;
predicate: string;
direction: "out" | "in";
wording: string;
other: EntityLink;
};
export type EntityView = {
id: string;
title: string;
@ -80,9 +98,38 @@ export type EntityView = {
kindId: string;
kindLabel: string;
kindHref: string;
graphHref: string;
glossary: string | null;
sources: { title: string; license: string }[];
claims: { key: string; status: string; value: string | null }[];
relations: RelationView[];
outgoing: RelationItem[];
incoming: RelationItem[];
relationRemainder: number;
collection: { title: string; members: EntityLink[] } | null;
related: EntityLink[];
readingPath: EntityLink[];
};
export type GraphNodeRecord = {
id: string;
title: string;
summary: string;
href: string;
};
export type GraphEdgeRecord = GraphEdge & {
forward: string;
inverse: string;
};
export type GraphDataset = {
dataspaceId: string;
dataspaceTitle: string;
nodes: GraphNodeRecord[];
edges: GraphEdgeRecord[];
labels: RelationLabels;
defaultFocusId: string;
};
type Loaded = {
@ -221,6 +268,42 @@ function hrefForEntity(loaded: Loaded, id: string): string | null {
return entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
function asIdList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
function linkFor(loaded: Loaded, id: string): EntityLink {
const entity = loaded.byId.get(id);
if (!entity) return { id, title: id, href: null, glossary: null, preview: null };
const props = asRecord(entity.properties);
const preview = asRecord(props?.preview);
const previewTitle = asText(preview?.title);
const previewSummary = asText(preview?.summary);
return {
id: entity.id,
title: entity.title,
href: hrefForEntity(loaded, entity.id),
glossary: asText(props?.glossary),
preview: previewTitle && previewSummary ? { title: previewTitle, summary: previewSummary } : null,
};
}
function labelRecord(loaded: Loaded): RelationLabels {
const labels: Record<string, { forward: string; inverse: string }> = {};
for (const [predicate, wording] of loaded.labels) labels[predicate] = wording;
return labels;
}
export function findEntity(dataspaceId: string, kindId: string, slug: string): EntityView | null {
const loaded = loadAll().get(dataspaceId);
if (!loaded) return null;
@ -232,27 +315,34 @@ export function findEntity(dataspaceId: string, kindId: string, slug: string): E
if (matches.length !== 1) return null;
const entity = matches[0];
const sources = new Map((loaded.snapshot.sources ?? []).map((source) => [source.id, source]));
const relations: RelationView[] = [];
for (const relation of loaded.snapshot.relations ?? []) {
const wording = loaded.labels.get(relation.predicate);
if (relation.from === entity.id) {
const other = loaded.byId.get(relation.to);
relations.push({
wording: wording?.forward ?? relation.predicate,
direction: "out",
otherTitle: other?.title ?? relation.to,
otherHref: hrefForEntity(loaded, relation.to),
});
} else if (relation.to === entity.id) {
const other = loaded.byId.get(relation.from);
relations.push({
wording: wording?.inverse ?? relation.predicate,
direction: "in",
otherTitle: other?.title ?? relation.from,
otherHref: hrefForEntity(loaded, relation.from),
});
}
}
const edges = loaded.snapshot.relations ?? [];
const blocks = partitionIncident(entity.id, edges, labelRecord(loaded));
const toItem = (relation: (typeof blocks.outgoing)[number]): RelationItem => ({
id: relation.id,
predicate: relation.predicate,
direction: relation.direction,
wording: relation.wording,
other: linkFor(loaded, relation.otherId),
});
const outgoing = blocks.outgoing.map(toItem);
const incoming = blocks.incoming.map(toItem);
const relations: RelationView[] = [...outgoing, ...incoming].map((relation) => ({
wording: relation.wording,
direction: relation.direction,
otherTitle: relation.other.title,
otherHref: relation.other.href,
}));
const props = asRecord(entity.properties);
const collection = asRecord(props?.collection);
const collectionTitle = asText(collection?.title);
const resolve = (id: string) => {
const link = linkFor(loaded, id);
return { title: link.title, href: link.href };
};
const readingIds = asIdList(props?.readingPath);
const reading = readingPathCrumbs(readingIds, resolve).map((step) => linkFor(loaded, step.id));
const related = memberLinks(asIdList(props?.related), resolve).map((step) => linkFor(loaded, step.id));
const self = linkFor(loaded, entity.id);
return {
id: entity.id,
title: entity.title,
@ -264,6 +354,8 @@ export function findEntity(dataspaceId: string, kindId: string, slug: string): E
kindId: kind.id,
kindLabel: kind.label,
kindHref: kindHref(dataspaceId, kind.id),
graphHref: `/${dataspaceId}/graph/?${new URLSearchParams({ focus: entity.id }).toString()}`,
glossary: self.glossary,
sources: (entity.sourceRefs ?? []).map((id) => {
const source = sources.get(id);
return { title: source?.title ?? id, license: source?.license ?? "" };
@ -272,5 +364,44 @@ export function findEntity(dataspaceId: string, kindId: string, slug: string): E
.filter((claim) => claim.subjectId === entity.id)
.map((claim) => ({ key: claim.key, status: claim.status, value: claim.value ?? null })),
relations,
outgoing,
incoming,
relationRemainder: blocks.remainder,
collection: collectionTitle
? { title: collectionTitle, members: memberLinks(asIdList(collection?.members), resolve).map((step) => linkFor(loaded, step.id)) }
: null,
related,
readingPath: reading,
};
}
export function findGraph(dataspaceId: string): GraphDataset | null {
const loaded = loadAll().get(dataspaceId);
if (!loaded || loaded.snapshot.entities.length === 0) return null;
const labels = labelRecord(loaded);
const nodes = loaded.snapshot.entities.map((entity) => ({
id: entity.id,
title: entity.title,
summary: entity.summary,
href: entityHref(loaded.snapshot.dataspace.id, entity.kindId, entity.id),
}));
const preferred = nodes.find((node) => node.id.endsWith(":feedback"));
return {
dataspaceId,
dataspaceTitle: loaded.snapshot.dataspace.title,
nodes,
edges: (loaded.snapshot.relations ?? []).map((relation) => {
const pair = labels[relation.predicate];
return {
id: relation.id,
predicate: relation.predicate,
from: relation.from,
to: relation.to,
forward: pair?.forward || relation.predicate,
inverse: pair?.inverse || relation.predicate,
};
}),
labels,
defaultFocusId: preferred?.id ?? nodes[0].id,
};
}

202
ui/lib/graph.test.ts Normal file
View file

@ -0,0 +1,202 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { findEntity, findGraph } from "./catalog";
import {
HOP_BOUND,
exportAdjacency,
memberLinks,
parseGraphSearch,
partitionIncident,
readingPathCrumbs,
selectNeighborhood,
serializeGraphSearch,
type GraphModel,
} from "./graph";
const root = join(process.cwd(), "../examples/dataspaces/systems-thinking");
function loadJson(path: string): {
entities: { id: string; properties?: { glossary?: string; preview?: { title: string; summary: string }; readingPath?: string[] } }[];
relations: { id: string; predicate: string; from: string; to: string }[];
} {
return JSON.parse(readFileSync(path, "utf8"));
}
describe("incident relations", () => {
it("splits feedback into forward and inverse blocks", () => {
const snapshot = loadJson(join(root, "snapshot.json"));
const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")) as {
relationLabels: Record<string, { forward: string; inverse: string }>;
};
const feedback = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"));
expect(feedback).toBeTruthy();
const blocks = partitionIncident(feedback!.id, snapshot.relations, manifest.relationLabels);
const incident = snapshot.relations.filter((relation) => relation.from === feedback!.id || relation.to === feedback!.id);
const listed = [...blocks.outgoing, ...blocks.incoming];
expect(blocks.remainder).toBe(0);
expect(listed.map((relation) => relation.id).sort()).toEqual(incident.map((relation) => relation.id).sort());
expect(new Set(listed.map((relation) => relation.direction)).size).toBe(2);
const represented = blocks.outgoing.find((relation) => relation.predicate === "represented-by");
expect(represented?.wording).toBe(manifest.relationLabels["represented-by"].forward);
expect(represented?.wording).toBe("Represented by");
const modeled = blocks.incoming.find((relation) => relation.predicate === "models");
expect(modeled?.wording).toBe(manifest.relationLabels.models.inverse);
expect(modeled?.wording).not.toBe(manifest.relationLabels.models.forward);
for (const relation of listed) {
const source = incident.find((item) => item.id === relation.id);
expect(source).toBeTruthy();
expect(relation.direction === "out" ? source!.from : source!.to).toBe(feedback!.id);
}
});
it("reports a remainder when incident relations are not all listed", () => {
const snapshot = loadJson(join(root, "snapshot.json"));
const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")) as {
relationLabels: Record<string, { forward: string; inverse: string }>;
};
const feedback = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"))!;
const incident = snapshot.relations.filter((relation) => relation.from === feedback.id || relation.to === feedback.id);
const blocks = partitionIncident(feedback.id, snapshot.relations, manifest.relationLabels, 1);
expect(blocks.outgoing.length + blocks.incoming.length).toBe(1);
expect(blocks.remainder).toBe(incident.length - 1);
expect(blocks.remainder).toBeGreaterThan(0);
});
it("keeps a self-loop in one block", () => {
const blocks = partitionIncident("a", [{ id: "loop", predicate: "related-to", from: "a", to: "a" }], {
"related-to": { forward: "Related to", inverse: "Related from" },
});
expect(blocks.outgoing.map((relation) => relation.id)).toEqual(["loop"]);
expect(blocks.incoming).toEqual([]);
expect(blocks.remainder).toBe(0);
});
});
describe("neighborhood", () => {
it("stops a cycle at the hop bound", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
{ id: "d", title: "D" },
{ id: "e", title: "E" },
],
edges: [
{ id: "ab", predicate: "p", from: "a", to: "b" },
{ id: "bc", predicate: "p", from: "b", to: "c" },
{ id: "ca", predicate: "p", from: "c", to: "a" },
{ id: "cd", predicate: "p", from: "c", to: "d" },
{ id: "de", predicate: "p", from: "d", to: "e" },
],
};
const bounded = selectNeighborhood(model, "a", 100);
expect(bounded.hops).toBe(HOP_BOUND);
expect(bounded.nodeIds).not.toContain("e");
expect(bounded.edges.map((edge) => edge.id)).not.toContain("de");
expect(bounded.excludedCount).toBe(model.edges.length - bounded.edges.length);
expect(bounded.excludedCount).toBeGreaterThan(0);
const again = selectNeighborhood(model, "a", HOP_BOUND);
expect(again.nodeIds).toEqual(bounded.nodeIds);
expect(again.edges).toEqual(bounded.edges);
});
it("keeps parallel edges and both parents", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "c", title: "C" },
],
edges: [
{ id: "e1", predicate: "p", from: "a", to: "b" },
{ id: "e2", predicate: "q", from: "a", to: "b" },
{ id: "parent-a", predicate: "parent", from: "a", to: "c" },
{ id: "parent-b", predicate: "parent", from: "b", to: "c" },
],
};
const fromA = selectNeighborhood(model, "a", 1);
const exported = exportAdjacency(fromA);
const parallel = exported.edges.filter((edge) => edge.from === "a" && edge.to === "b");
expect(parallel.map((edge) => edge.id).sort()).toEqual(["e1", "e2"]);
expect(parallel.map((edge) => edge.predicate).sort()).toEqual(["p", "q"]);
expect(new Set(parallel.map((edge) => edge.direction))).toEqual(new Set(["out"]));
const fromC = selectNeighborhood(model, "c", 1);
const parents = exportAdjacency(fromC).edges.filter((edge) => edge.predicate === "parent");
expect(parents.map((edge) => edge.id).sort()).toEqual(["parent-a", "parent-b"]);
expect(fromC.nodeIds).toEqual(expect.arrayContaining(["a", "b", "c"]));
});
it("keeps a disconnected node addressable", () => {
const model: GraphModel = {
nodes: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
{ id: "solo", title: "Solo" },
],
edges: [{ id: "ab", predicate: "p", from: "a", to: "b" }],
};
expect(model.nodes.map((node) => node.id)).toContain("solo");
const alone = selectNeighborhood(model, "solo", 2);
expect(alone.nodeIds).toEqual(["solo"]);
expect(alone.edges).toEqual([]);
expect(alone.excludedCount).toBe(model.edges.length);
const connected = selectNeighborhood(model, "a", 2);
expect(connected.nodeIds).not.toContain("solo");
expect(connected.excludedCount).toBe(0);
});
it("keeps a synthetic graph inside the hop bound and counts the rest", () => {
const model = JSON.parse(readFileSync(join(process.cwd(), "fixtures/large-graph.json"), "utf8")) as GraphModel;
expect(model.edges.length).toBeGreaterThan(HOP_BOUND);
const one = selectNeighborhood(model, "n0", 1);
const two = selectNeighborhood(model, "n0", HOP_BOUND);
const forced = selectNeighborhood(model, "n0", 50);
expect(two.hops).toBe(HOP_BOUND);
expect(forced).toEqual(two);
expect(two.nodeIds.length).toBeGreaterThan(one.nodeIds.length);
expect(two.nodeIds.length).toBeLessThan(model.nodes.length);
expect(two.nodeIds).toContain("n0");
expect(two.nodeIds).not.toContain("n8");
const inside = new Set(two.nodeIds);
const omitted = model.edges.filter((edge) => !inside.has(edge.from) || !inside.has(edge.to));
expect(two.excludedCount).toBe(omitted.length);
expect(two.edges.length + two.excludedCount).toBe(model.edges.length);
expect(two.excludedCount).toBeGreaterThan(0);
const exported = exportAdjacency(two);
expect(exported.edges.map((edge) => edge.id)).toEqual(two.edges.map((edge) => edge.id));
expect(exported.edges.every((edge) => edge.direction === "out" && edge.predicate.length > 0)).toBe(true);
const solo = selectNeighborhood(model, "solo", 2);
expect(model.nodes.some((node) => node.id === "solo")).toBe(true);
expect(solo.nodeIds).toEqual(["solo"]);
expect(solo.excludedCount).toBe(model.edges.length);
});
});
describe("journeys", () => {
it("keeps reading-path order and collection members", () => {
const steps = readingPathCrumbs(["boundary", "feedback", "diagram"], (id) => ({ title: id, href: `/${id}/` }));
expect(steps.map((step) => step.id)).toEqual(["boundary", "feedback", "diagram"]);
expect(steps[0]?.href).toBe("/boundary/");
const members = memberLinks(["left", "right"], (id) => ({ title: id.toUpperCase(), href: `/${id}/` }));
expect(members.map((member) => member.title)).toEqual(["LEFT", "RIGHT"]);
const feedback = findEntity("systems-thinking", "concept", "feedback");
const snapshot = loadJson(join(root, "snapshot.json"));
const declared = snapshot.entities.find((entity) => entity.id.endsWith(":feedback"))?.properties?.readingPath ?? [];
expect(feedback?.readingPath.map((step) => step.id)).toEqual(declared);
expect(feedback?.collection?.members.length).toBeGreaterThan(1);
expect(feedback?.related.length).toBeGreaterThan(0);
});
it("round-trips focus and detail in the query", () => {
const graph = findGraph("systems-thinking");
expect(graph).not.toBeNull();
const ids = graph!.nodes.map((node) => node.id);
const focus = ids.find((id) => id.endsWith(":feedback")) ?? ids[0];
const detail = ids.find((id) => id.endsWith(":causal-loop")) ?? ids[1];
const search = serializeGraphSearch({ focus, detail, hops: 1 });
expect(parseGraphSearch(search, ids, graph!.defaultFocusId)).toEqual({ focus, detail, hops: 1 });
expect(parseGraphSearch("", ids, graph!.defaultFocusId).focus).toBe(graph!.defaultFocusId);
});
});

234
ui/lib/graph.ts Normal file
View 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);
},
};
}

View file

@ -4,19 +4,19 @@ type: workplan
title: "Relation explorer and knowledge journeys"
domain: infotech
repo: content-explorer
status: proposed
status: finished
flavor: implementation
depends_on:
- CONTENT-WP-0002
- CONTENT-WP-0003
created: "2026-10-12"
updated: "2026-10-12"
updated: "2026-10-19"
state_hub_workstream_id: "d78fa2e0-9052-5e3b-93b6-e8c86248bad1"
---
# Relation explorer and knowledge journeys
Make interconnected knowledge explorable beyond the basic browse and detail path.
Make interconnected knowledge explorable beyond the basic browse and detail path. T01 through T08 are done. See `workplans/evidence/CONTENT-WP-0004-ImplementationNotes.md` and `workplans/evidence/CONTENT-WP-0004-Review.md`.
## Work packages
@ -24,7 +24,7 @@ Make interconnected knowledge explorable beyond the basic browse and detail path
```task
id: CONTENT-WP-0004-T01
status: todo
status: done
priority: high
state_hub_task_id: "e64d9cdd-10ee-5a6e-b714-76037ca8bacc"
```
@ -33,7 +33,7 @@ state_hub_task_id: "e64d9cdd-10ee-5a6e-b714-76037ca8bacc"
```task
id: CONTENT-WP-0004-T02
status: todo
status: done
priority: high
state_hub_task_id: "78c2c68d-fe5c-532e-892d-73939d647bbe"
```
@ -42,7 +42,7 @@ state_hub_task_id: "78c2c68d-fe5c-532e-892d-73939d647bbe"
```task
id: CONTENT-WP-0004-T03
status: todo
status: done
priority: high
state_hub_task_id: "fad1f2fb-0f97-5512-88ae-dff430004811"
```
@ -51,7 +51,7 @@ state_hub_task_id: "fad1f2fb-0f97-5512-88ae-dff430004811"
```task
id: CONTENT-WP-0004-T04
status: todo
status: done
priority: high
state_hub_task_id: "274e7a5b-ce17-51c6-a073-6945b9a34ff8"
```
@ -60,7 +60,7 @@ state_hub_task_id: "274e7a5b-ce17-51c6-a073-6945b9a34ff8"
```task
id: CONTENT-WP-0004-T05
status: todo
status: done
priority: high
state_hub_task_id: "edf84d8c-8913-544a-9f39-3ef1d8a99468"
```
@ -71,7 +71,7 @@ state_hub_task_id: "edf84d8c-8913-544a-9f39-3ef1d8a99468"
```task
id: CONTENT-WP-0004-T06
status: todo
status: done
priority: high
state_hub_task_id: "d582553b-3966-55e0-b294-85d6cb42e516"
```
@ -80,7 +80,7 @@ state_hub_task_id: "d582553b-3966-55e0-b294-85d6cb42e516"
```task
id: CONTENT-WP-0004-T07
status: todo
status: done
priority: high
state_hub_task_id: "61d43835-8da7-5af3-9368-c3aa3120f486"
```
@ -89,7 +89,7 @@ state_hub_task_id: "61d43835-8da7-5af3-9368-c3aa3120f486"
```task
id: CONTENT-WP-0004-T08
status: todo
status: done
priority: high
state_hub_task_id: "018814db-3aab-5fd2-bebf-8e4c7d7047d2"
```

View file

@ -0,0 +1,59 @@
# CONTENT-WP-0004 implementation notes
Date: 2026-10-19
Hop bound: 2 (`HOP_BOUND` in `ui/lib/graph.ts`). A requested distance above 2 is clamped. Neighborhood walks are undirected. Each edge keeps its stored `from`, `to`, predicate, and id.
Journey fields did not require a schema bump. Manifest and snapshot 0.1 still set `additionalProperties: false` at the document root, and entity `properties` is already an open object. Collections, related ids, reading paths, glossary text, and link previews live there. Today's fixtures still validate.
## T01 — outgoing and incoming blocks
`partitionIncident` places every relation that touches an entity in exactly one block. Outgoing wording is the manifest forward label. Incoming wording is the inverse label. A self-loop is outgoing. `EntityDetail` renders the two blocks separately. When a caller lists only part of the incident set, `relationRemainder` is shown as “N more relations are not listed.”
Systems-thinking Feedback uses forward “Represented by” on `represented-by`. The incoming `models` edge uses “Modeled by”, which is not the forward string “Models”.
## T02 and T05 — bounded graph
`selectNeighborhood` returns the focus plus nodes within the hop bound. Cycles stop because each node is visited once. Parallel edges stay as separate adjacency records (`id`, `predicate`, `from`, `to`, `direction: "out"`). Both parents of a node stay. A node with no edges remains in the graph model and can be focused. The route is `/{dataspace}/graph?focus=<entity id>&detail=<entity id>&hops=1`. Hop 2 omits `hops`. The page shows the excluded-relation count.
## T03 — collections, related objects, reading paths
A declared `properties.collection` lists member links. `properties.related` lists related objects. `properties.readingPath` is rendered in that order. The crumb for the open entity is the current step. Each earlier crumb is a link to that step.
Feedback declares the collection “Ways to draw feedback”, a related link to Emergence, and the path System boundary → Feedback → Causal loop diagram.
## T04 — glossary and previews
`RecordLink` shows a glossary tooltip only when the target has `properties.glossary`, and a preview only when `properties.preview` has both a title and a summary. Causal loop diagram has both. Stock and flow model has neither: its link still resolves, and the page does not render a tooltip or preview for it. System boundary has no journey properties and still renders.
## T06 — nothing incident is silent
The Feedback page lists every incident relation id from the snapshot in exactly one block. The graph page prints the count of relations outside the neighborhood.
## T07 — focus and detail in the URL
`GraphExplorer` reads and writes `focus` and `detail` through the same history helper the page uses in the browser. Reload passes the query back in as `initialSearch`. Back and forward restore the previous focus and detail. Covered in `ui/components/GraphExplorer.history.test.tsx`.
## T08 — synthetic fixture
`ui/fixtures/large-graph.json` is a chain `n0`–`n8` (8 edges) plus an isolated node `solo`. It is a test fixture, not a published dataspace, so `validate_fixtures.py` still checks only the two example dataspaces.
From `n0` at hop bound 2, the neighborhood is `n0`, `n1`, and `n2`. `n8` is outside. `excludedCount` is 6, equal to the edges that do not have both ends inside that neighborhood. Focusing `solo` returns that node and excludes all 8 edges.
## Checks
Durable tests:
- `ui/lib/graph.test.ts`
- `ui/components/EntityDetail.test.tsx`
- `ui/components/GraphExplorer.history.test.tsx`
Captured on 2026-10-19 under `/tmp/grok-goal-aedf5fe6975e/implementer/`:
- `ui-test-1.txt` and `ui-test-2.txt`: `npm test` in `ui/`, 11 files, 32 tests, both exit 0.
- `typecheck.txt`: `npm run typecheck` in `ui/`, exit 0.
- `fixtures.txt`: `python3 scripts/validate_fixtures.py`, `python3 -m unittest tests.test_contract tests.test_ingest`, and `python3 scripts/check_ui_boundary.py`, exit 0.
- `launch-1.html` and `launch-2.html`: HTTP 200 for `/systems-thinking/graph?focus=urn:ce:systems-thinking:concept:feedback`. Both bodies contain “Represented by” and “Modeled by”.
- `graph.png`: headless Chromium loaded that URL with no page error. Focusing Causal loop diagram changed the neighborhood and the `focus` query. The screenshot is not blank.
No R6 deviation. The safeguard is the hop bound of 2, not a graph database or a draw-everything view.

View file

@ -0,0 +1,11 @@
# CONTENT-WP-0004 review
Date: 2026-10-19
Hop bound: 2 (`HOP_BOUND` in `ui/lib/graph.ts`). Requests above 2 are clamped to that bound.
Synthetic fixture: `ui/fixtures/large-graph.json`, a chain of 9 nodes (`n0`–`n8`, 8 edges) plus the isolated node `solo`. From `n0` at the hop bound, the neighborhood is `n0`, `n1`, and `n2`. `n8` stays outside. `excludedCount` is 6, matching the relations left out. Focusing `solo` keeps that node in the model and excludes all 8 edges. The same functions cover a cycle, two predicates between one pair, and a node with two parents (`ui/lib/graph.test.ts`).
R6: no deviation. Large-graph handling is the hop bound. Nodes and relations past it are counted as excluded. They are not drawn, and they are not dropped without that count.
T01–T08 are done. The test log is `npm test` in `ui/` (32 tests, two runs, both exit 0), saved as `/tmp/grok-goal-aedf5fe6975e/implementer/ui-test-1.txt` and `ui-test-2.txt`. The in-repo proof is `ui/lib/graph.test.ts`, `ui/components/EntityDetail.test.tsx`, and `ui/components/GraphExplorer.history.test.tsx`. Fixture validation and the dev-server bodies are recorded in `workplans/evidence/CONTENT-WP-0004-ImplementationNotes.md`.