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
|
|
@ -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",
|
||||
|
|
|
|||
37
ui/app/[dataspace]/graph/page.tsx
Normal file
37
ui/app/[dataspace]/graph/page.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
177
ui/components/GraphExplorer.history.test.tsx
Normal file
177
ui/components/GraphExplorer.history.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
143
ui/components/GraphExplorer.tsx
Normal file
143
ui/components/GraphExplorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
ui/components/RecordLink.tsx
Normal file
28
ui/components/RecordLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
24
ui/fixtures/large-graph.json
Normal file
24
ui/fixtures/large-graph.json
Normal 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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
202
ui/lib/graph.test.ts
Normal 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
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
59
workplans/evidence/CONTENT-WP-0004-ImplementationNotes.md
Normal file
59
workplans/evidence/CONTENT-WP-0004-ImplementationNotes.md
Normal 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.
|
||||
11
workplans/evidence/CONTENT-WP-0004-Review.md
Normal file
11
workplans/evidence/CONTENT-WP-0004-Review.md
Normal 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue