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