diff --git a/apps/web/src/graph/LocalGraphPanel.tsx b/apps/web/src/graph/LocalGraphPanel.tsx new file mode 100644 index 0000000..787d5b5 --- /dev/null +++ b/apps/web/src/graph/LocalGraphPanel.tsx @@ -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(`/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>(); + 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([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 ( +
+ {t('localTitle')} +
+ {([1, 2] as const).map((depth) => ( + + ))} +
+
+ { + const slug = slugById.get(id); + if (slug && id !== pageId) navigate(`/p/${pondSlug}/${slug}`); + }} + /> +
+
+ ); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index e3c37f8..dc69cee 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -18,6 +18,7 @@ import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AttachmentsPanel } from '../files/AttachmentsPanel'; import { HistoryPanel } from '../editor/HistoryPanel'; import { LabelPicker } from '../labels/LabelPicker'; +import { LocalGraphPanel } from '../graph/LocalGraphPanel'; import { BacklinksPanel } from '../links/BacklinksPanel'; import { collaborationCaretFor } from '../editor/collaboration-caret'; import { documentExtensions } from '../editor/document-extensions'; @@ -427,8 +428,12 @@ export function PageEditorPage(): React.JSX.Element { )} - {/* "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' && } + {mode === 'view' && ( + + )} ); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index d3190d1..6e7eb7a 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1828,6 +1828,31 @@ button { 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 { max-width: 48rem;