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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

234
ui/lib/graph.ts Normal file
View file

@ -0,0 +1,234 @@
/**
* Neighborhood, incident relations, and reading order for the relation explorer.
* Hop distance is capped at HOP_BOUND. Domain wording stays in the manifest.
*/
export const HOP_BOUND = 2;
export type GraphNode = {
id: string;
title: string;
};
export type GraphEdge = {
id: string;
predicate: string;
from: string;
to: string;
};
export type GraphModel = {
nodes: readonly GraphNode[];
edges: readonly GraphEdge[];
};
export type RelationLabels = Readonly<Record<string, { forward?: string; inverse?: string }>>;
export type IncidentRelation = {
id: string;
predicate: string;
direction: "out" | "in";
wording: string;
otherId: string;
};
export type RelationBlocks = {
outgoing: IncidentRelation[];
incoming: IncidentRelation[];
remainder: number;
};
export type Neighborhood = {
focusId: string;
hops: 1 | 2;
nodeIds: string[];
edges: GraphEdge[];
excludedCount: number;
};
export type AdjacencyEdge = {
id: string;
predicate: string;
from: string;
to: string;
direction: "out";
};
export type AdjacencyExport = {
nodes: string[];
edges: AdjacencyEdge[];
};
export type GraphLocation = {
focus: string;
detail: string | null;
hops: 1 | 2;
};
export type GraphHistory = {
read: () => string;
push: (search: string) => void;
subscribe: (listener: () => void) => () => void;
};
function clampHops(hops: number): 1 | 2 {
return hops <= 1 ? 1 : HOP_BOUND;
}
/** Undirected distance from the focus, capped at two hops. Edge direction is kept. */
export function selectNeighborhood(model: GraphModel, focusId: string, hops: number): Neighborhood {
const maxHops = clampHops(hops);
const known = new Set(model.nodes.map((node) => node.id));
if (!known.has(focusId)) {
return { focusId, hops: maxHops, nodeIds: [], edges: [], excludedCount: model.edges.length };
}
const links = new Map<string, { other: string }[]>();
for (const node of model.nodes) links.set(node.id, []);
for (const edge of model.edges) {
if (!known.has(edge.from) || !known.has(edge.to)) continue;
links.get(edge.from)?.push({ other: edge.to });
if (edge.to !== edge.from) links.get(edge.to)?.push({ other: edge.from });
}
const distance = new Map<string, number>([[focusId, 0]]);
const queue = [focusId];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) break;
const currentDistance = distance.get(current) ?? 0;
if (currentDistance >= maxHops) continue;
for (const link of links.get(current) ?? []) {
if (distance.has(link.other)) continue;
distance.set(link.other, currentDistance + 1);
queue.push(link.other);
}
}
const nodeIds = [...distance.keys()];
const inside = new Set(nodeIds);
const edges = model.edges.filter((edge) => inside.has(edge.from) && inside.has(edge.to));
return {
focusId,
hops: maxHops,
nodeIds,
edges,
excludedCount: model.edges.length - edges.length,
};
}
/** One record per in-scope edge. Parallel edges stay separate and keep their direction. */
export function exportAdjacency(neighborhood: Neighborhood): AdjacencyExport {
return {
nodes: [...neighborhood.nodeIds],
edges: neighborhood.edges.map((edge) => ({
id: edge.id,
predicate: edge.predicate,
from: edge.from,
to: edge.to,
direction: "out",
})),
};
}
function wordingFor(labels: RelationLabels, predicate: string, direction: "out" | "in"): string {
const pair = labels[predicate];
if (direction === "out") return pair?.forward || predicate;
return pair?.inverse || predicate;
}
/**
* Place every relation that touches entityId in exactly one block.
* A self-loop is outgoing. `limit` lists only the first incident relations
* and reports the rest as `remainder`.
*/
export function partitionIncident(
entityId: string,
edges: readonly GraphEdge[],
labels: RelationLabels,
limit?: number,
): RelationBlocks {
const incident: IncidentRelation[] = [];
for (const edge of edges) {
if (edge.from === entityId) {
incident.push({
id: edge.id,
predicate: edge.predicate,
direction: "out",
wording: wordingFor(labels, edge.predicate, "out"),
otherId: edge.to,
});
} else if (edge.to === entityId) {
incident.push({
id: edge.id,
predicate: edge.predicate,
direction: "in",
wording: wordingFor(labels, edge.predicate, "in"),
otherId: edge.from,
});
}
}
const capped = limit != null && limit < incident.length ? incident.slice(0, Math.max(0, limit)) : incident;
const listed = new Set(capped.map((item) => item.id));
return {
outgoing: capped.filter((item) => item.direction === "out"),
incoming: capped.filter((item) => item.direction === "in"),
remainder: incident.length - capped.length,
};
}
export type ResolvedStep = {
id: string;
title: string;
href: string | null;
};
/** Keep declared path order. An unknown id stays visible as its own id. */
export function readingPathCrumbs(
path: readonly string[],
resolve: (id: string) => { title: string; href: string | null } | null,
): ResolvedStep[] {
return path.map((id) => {
const found = resolve(id);
return found ? { id, title: found.title, href: found.href } : { id, title: id, href: null };
});
}
export function memberLinks(
memberIds: readonly string[],
resolve: (id: string) => { title: string; href: string | null } | null,
): ResolvedStep[] {
return readingPathCrumbs(memberIds, resolve);
}
export function parseGraphSearch(search: string, knownIds: readonly string[], fallbackId: string): GraphLocation {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
const known = new Set(knownIds);
const requested = params.get("focus");
const detail = params.get("detail");
const hops = params.get("hops") === "1" ? 1 : HOP_BOUND;
return {
focus: requested && known.has(requested) ? requested : fallbackId,
detail: detail && known.has(detail) ? detail : null,
hops,
};
}
export function serializeGraphSearch(location: GraphLocation): string {
const params = new URLSearchParams();
params.set("focus", location.focus);
if (location.detail) params.set("detail", location.detail);
if (location.hops === 1) params.set("hops", "1");
return params.toString();
}
export function browserGraphHistory(): GraphHistory {
return {
read: () => (typeof window === "undefined" ? "" : window.location.search),
push: (search) => {
const path = window.location.pathname;
window.history.pushState(null, "", search ? `${path}?${search}` : path);
},
subscribe: (listener) => {
window.addEventListener("popstate", listener);
return () => window.removeEventListener("popstate", listener);
},
};
}