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
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue