content-explorer/ui/components/GraphExplorer.tsx

144 lines
5.4 KiB
TypeScript
Raw Normal View History

"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>
);
}