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
143 lines
5.4 KiB
TypeScript
143 lines
5.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|