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

@ -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}