Local neighborhood graph on the page (#113)
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 2m26s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m21s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 2m26s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m21s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
A collapsible 'Local graph' panel joins the backlinks below the page content in read mode: the current page (highlight ring) with its wikilink neighbors in both directions, switchable between direct neighbors and two hops. Computed client-side by BFS over the cached pond-wide graph response — no second endpoint; the TanStack query is shared with the pond graph view. Phantom targets render dashed; a click navigates to the neighbor; pages without any links show no panel at all. Reuses the ForceGraph renderer from #112 unchanged. Verified live: hop toggle reveals the second-hop page, ring on the current page, click-through, and the panel's absence on a lonely page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
dbbe229cb9
commit
a72cb1b1c5
143
apps/web/src/graph/LocalGraphPanel.tsx
Normal file
143
apps/web/src/graph/LocalGraphPanel.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import type { PondGraphView } from '@dorfteich/shared';
|
||||||
|
import { DEFAULT_LABEL_COLOR } from '@dorfteich/shared';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
import { ForceGraph, type ForceGraphEdge, type ForceGraphNode } from './ForceGraph';
|
||||||
|
|
||||||
|
const PHANTOM_PREFIX = 'phantom:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current page's neighborhood graph (issue #113): direct wikilink
|
||||||
|
* neighbors in both directions, optionally two hops. Computed client-side
|
||||||
|
* from the cached pond-wide graph (`/ponds/:id/links`, shared with the pond
|
||||||
|
* view #112) — ponds are small, so no extra endpoint. Collapsible below the
|
||||||
|
* content like the backlinks panel; invisible for link-less pages.
|
||||||
|
*/
|
||||||
|
export function LocalGraphPanel({
|
||||||
|
pageId,
|
||||||
|
pondId,
|
||||||
|
pondSlug,
|
||||||
|
}: {
|
||||||
|
pageId: string;
|
||||||
|
pondId: string;
|
||||||
|
pondSlug: string;
|
||||||
|
}): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation('graph');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [hops, setHops] = useState<1 | 2>(1);
|
||||||
|
|
||||||
|
const graph = useQuery({
|
||||||
|
queryKey: ['pond-links', pondId],
|
||||||
|
queryFn: () => apiGet<PondGraphView>(`/ponds/${pondId}/links`),
|
||||||
|
});
|
||||||
|
const { byId } = usePondLabels(pondId);
|
||||||
|
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
const data = graph.data;
|
||||||
|
const empty = { nodes: [] as ForceGraphNode[], edges: [] as ForceGraphEdge[] };
|
||||||
|
if (!data) return empty;
|
||||||
|
|
||||||
|
// Undirected adjacency over resolved links and phantom references.
|
||||||
|
const allEdges: ForceGraphEdge[] = [
|
||||||
|
...data.edges.map((edge) => ({ from: edge.from, to: edge.to })),
|
||||||
|
...data.phantoms.flatMap((phantom) =>
|
||||||
|
phantom.referencedBy.map((referrer) => ({
|
||||||
|
from: referrer,
|
||||||
|
to: `${PHANTOM_PREFIX}${phantom.targetSlug}`,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const neighborsOf = new Map<string, Set<string>>();
|
||||||
|
for (const edge of allEdges) {
|
||||||
|
(neighborsOf.get(edge.from) ?? neighborsOf.set(edge.from, new Set()).get(edge.from)!).add(
|
||||||
|
edge.to,
|
||||||
|
);
|
||||||
|
(neighborsOf.get(edge.to) ?? neighborsOf.set(edge.to, new Set()).get(edge.to)!).add(
|
||||||
|
edge.from,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS up to `hops` levels from the current page.
|
||||||
|
const reachable = new Set<string>([pageId]);
|
||||||
|
let frontier = [pageId];
|
||||||
|
for (let depth = 0; depth < hops; depth += 1) {
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const id of frontier) {
|
||||||
|
for (const neighbor of neighborsOf.get(id) ?? []) {
|
||||||
|
if (reachable.has(neighbor)) continue;
|
||||||
|
reachable.add(neighbor);
|
||||||
|
next.push(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
if (reachable.size <= 1) return empty;
|
||||||
|
|
||||||
|
const nodes: ForceGraphNode[] = [];
|
||||||
|
for (const node of data.nodes) {
|
||||||
|
if (!reachable.has(node.id)) continue;
|
||||||
|
const label = node.labelIds.map((id) => byId.get(id)).find(Boolean);
|
||||||
|
nodes.push({
|
||||||
|
id: node.id,
|
||||||
|
label: node.title,
|
||||||
|
color: label?.color ?? DEFAULT_LABEL_COLOR,
|
||||||
|
highlight: node.id === pageId,
|
||||||
|
testId: `local-graph-node-${node.slug}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const phantom of data.phantoms) {
|
||||||
|
const id = `${PHANTOM_PREFIX}${phantom.targetSlug}`;
|
||||||
|
if (!reachable.has(id)) continue;
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
label: phantom.targetSlug,
|
||||||
|
color: DEFAULT_LABEL_COLOR,
|
||||||
|
dashed: true,
|
||||||
|
testId: `local-graph-phantom-${phantom.targetSlug}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const edges = allEdges.filter((edge) => reachable.has(edge.from) && reachable.has(edge.to));
|
||||||
|
return { nodes, edges };
|
||||||
|
}, [graph.data, byId, pageId, hops]);
|
||||||
|
|
||||||
|
// Invisible for pages without any wikilink neighborhood.
|
||||||
|
if (nodes.length === 0) return null;
|
||||||
|
|
||||||
|
const slugById = new Map((graph.data?.nodes ?? []).map((node) => [node.id, node.slug]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="local-graph" open>
|
||||||
|
<summary>{t('localTitle')}</summary>
|
||||||
|
<div className="local-graph__controls">
|
||||||
|
{([1, 2] as const).map((depth) => (
|
||||||
|
<button
|
||||||
|
key={depth}
|
||||||
|
type="button"
|
||||||
|
className={`sidebar__view-btn${hops === depth ? ' sidebar__view-btn--active' : ''}`}
|
||||||
|
aria-pressed={hops === depth}
|
||||||
|
onClick={() => setHops(depth)}
|
||||||
|
>
|
||||||
|
{t(depth === 1 ? 'localDepth1' : 'localDepth2')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="local-graph__canvas">
|
||||||
|
<ForceGraph
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
width={560}
|
||||||
|
height={320}
|
||||||
|
onNodeClick={(id) => {
|
||||||
|
const slug = slugById.get(id);
|
||||||
|
if (slug && id !== pageId) navigate(`/p/${pondSlug}/${slug}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
|||||||
import { AttachmentsPanel } from '../files/AttachmentsPanel';
|
import { AttachmentsPanel } from '../files/AttachmentsPanel';
|
||||||
import { HistoryPanel } from '../editor/HistoryPanel';
|
import { HistoryPanel } from '../editor/HistoryPanel';
|
||||||
import { LabelPicker } from '../labels/LabelPicker';
|
import { LabelPicker } from '../labels/LabelPicker';
|
||||||
|
import { LocalGraphPanel } from '../graph/LocalGraphPanel';
|
||||||
import { BacklinksPanel } from '../links/BacklinksPanel';
|
import { BacklinksPanel } from '../links/BacklinksPanel';
|
||||||
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
import { collaborationCaretFor } from '../editor/collaboration-caret';
|
||||||
import { documentExtensions } from '../editor/document-extensions';
|
import { documentExtensions } from '../editor/document-extensions';
|
||||||
@ -427,8 +428,12 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* "Linked from" appears below the content in read mode (issue #48). */}
|
{/* "Linked from" appears below the content in read mode (issue #48);
|
||||||
|
the local neighborhood graph joins it there (issue #113). */}
|
||||||
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
|
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
|
||||||
|
{mode === 'view' && (
|
||||||
|
<LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PondFontScope>
|
</PondFontScope>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1828,6 +1828,31 @@ button {
|
|||||||
border: 2px dashed var(--color-text-muted);
|
border: 2px dashed var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Local neighborhood graph below the page content (issue #113). */
|
||||||
|
.local-graph {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-graph > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-graph__controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-graph__canvas {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface);
|
||||||
|
max-width: 36rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pond settings page + label manager. */
|
/* Pond settings page + label manager. */
|
||||||
.pond-settings-page {
|
.pond-settings-page {
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user