Pond knowledge graph view (#112)
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
/p/:pondSlug/graph (static segment ranked above :pageSlug, same documented reserved-slug gap as trash/settings) renders the pond's readable wikilink graph from GET /ponds/:id/links: pages as nodes colored by their first label (legend included, DEFAULT_LABEL_COLOR for unlabeled), resolved links as edges, phantom targets as dashed nodes — clicking one offers to create the page, which resolves its links. Rendering is a self-contained SVG force graph: only d3-force is bundled (no d3 DOM/zoom modules, zero external requests); the layout runs synchronously to rest, zoom/pan/node-drag are plain pointer math. SVG over canvas deliberately — every node carries a data-testid the e2e packs can click. Ponds beyond 500 pages get a capped-view notice. Sidebar footer links every member to the graph (trash stays owner-only). New i18n namespace graph (de+en). Verified live: nodes/edges/legend render, node click opens the page, phantom click creates it and the node turns solid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ffcc337ed0
commit
dbbe229cb9
@ -25,6 +25,7 @@
|
|||||||
"@tiptap/extension-collaboration-caret": "^3.27.1",
|
"@tiptap/extension-collaboration-caret": "^3.27.1",
|
||||||
"@tiptap/pm": "^3.27.1",
|
"@tiptap/pm": "^3.27.1",
|
||||||
"@tiptap/react": "^3.27.1",
|
"@tiptap/react": "^3.27.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.24.0",
|
"lucide-react": "^1.24.0",
|
||||||
@ -42,6 +43,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
|
import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
|
||||||
|
import { PondGraphPage } from './graph/PondGraphPage';
|
||||||
import { MAINTENANCE_EVENT } from './lib/api';
|
import { MAINTENANCE_EVENT } from './lib/api';
|
||||||
import { MaintenancePage } from './pages/MaintenancePage';
|
import { MaintenancePage } from './pages/MaintenancePage';
|
||||||
import { AppLayout } from './layout/AppLayout';
|
import { AppLayout } from './layout/AppLayout';
|
||||||
@ -90,6 +91,8 @@ export function App(): React.JSX.Element {
|
|||||||
<Route path="p/:pondSlug/trash" element={<TrashPage />} />
|
<Route path="p/:pondSlug/trash" element={<TrashPage />} />
|
||||||
{/* Static "settings" wins over :pageSlug, like "trash" above. */}
|
{/* Static "settings" wins over :pageSlug, like "trash" above. */}
|
||||||
<Route path="p/:pondSlug/settings" element={<PondSettingsPage />} />
|
<Route path="p/:pondSlug/settings" element={<PondSettingsPage />} />
|
||||||
|
{/* Static "graph" wins over :pageSlug, like "trash" above (#112). */}
|
||||||
|
<Route path="p/:pondSlug/graph" element={<PondGraphPage />} />
|
||||||
<Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} />
|
<Route path="p/:pondSlug/:pageSlug" element={<PageEditorPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireSiteAdmin />}>
|
<Route element={<RequireSiteAdmin />}>
|
||||||
|
|||||||
227
apps/web/src/graph/ForceGraph.tsx
Normal file
227
apps/web/src/graph/ForceGraph.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
forceCenter,
|
||||||
|
forceCollide,
|
||||||
|
forceLink,
|
||||||
|
forceManyBody,
|
||||||
|
forceSimulation,
|
||||||
|
type SimulationLinkDatum,
|
||||||
|
type SimulationNodeDatum,
|
||||||
|
} from 'd3-force';
|
||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-contained SVG force graph (issue #112). Only `d3-force` is bundled —
|
||||||
|
* no d3 DOM/zoom modules, no external requests: layout runs synchronously
|
||||||
|
* (deterministic phyllotaxis seed), zoom/pan/drag are plain pointer math.
|
||||||
|
* SVG over canvas deliberately: nodes carry data-testids, so the e2e packs
|
||||||
|
* can click them. Shared by the pond graph view and the local panel (#113).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ForceGraphNode {
|
||||||
|
/** Stable id — page id or a `phantom:<slug>` key. */
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
/** Phantom targets render dashed and muted. */
|
||||||
|
dashed?: boolean;
|
||||||
|
/** The current page in the local graph gets a highlight ring. */
|
||||||
|
highlight?: boolean;
|
||||||
|
/** Hooks the e2e packs click on. */
|
||||||
|
testId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForceGraphEdge {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutNode extends SimulationNodeDatum {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICKS = 250;
|
||||||
|
const MIN_ZOOM = 0.2;
|
||||||
|
const MAX_ZOOM = 5;
|
||||||
|
|
||||||
|
/** Runs the force layout to rest and returns node positions keyed by id. */
|
||||||
|
function layout(
|
||||||
|
nodeIds: string[],
|
||||||
|
edges: ForceGraphEdge[],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
const nodes: LayoutNode[] = nodeIds.map((id) => ({ id }));
|
||||||
|
const links: SimulationLinkDatum<LayoutNode>[] = edges.map((edge) => ({
|
||||||
|
source: edge.from,
|
||||||
|
target: edge.to,
|
||||||
|
}));
|
||||||
|
const simulation = forceSimulation(nodes)
|
||||||
|
.force('charge', forceManyBody().strength(-160))
|
||||||
|
.force(
|
||||||
|
'link',
|
||||||
|
forceLink<LayoutNode, SimulationLinkDatum<LayoutNode>>(links)
|
||||||
|
.id((node) => node.id)
|
||||||
|
.distance(70),
|
||||||
|
)
|
||||||
|
.force('center', forceCenter(0, 0))
|
||||||
|
.force('collide', forceCollide(22))
|
||||||
|
.stop();
|
||||||
|
simulation.tick(TICKS);
|
||||||
|
// Keep the resting layout inside the viewBox for typical pond sizes.
|
||||||
|
const scale = Math.min(
|
||||||
|
1,
|
||||||
|
...nodes.map((n) =>
|
||||||
|
Math.min(Math.abs((width / 2 - 30) / (n.x || 1)), Math.abs((height / 2 - 30) / (n.y || 1))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return new Map(nodes.map((n) => [n.id, { x: (n.x ?? 0) * scale, y: (n.y ?? 0) * scale }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForceGraph({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
width = 800,
|
||||||
|
height = 560,
|
||||||
|
onNodeClick,
|
||||||
|
}: {
|
||||||
|
nodes: ForceGraphNode[];
|
||||||
|
edges: ForceGraphEdge[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
onNodeClick?: (id: string) => void;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
// Callers memoize nodes/edges (they come from query-derived useMemos), so
|
||||||
|
// the layout runs only when the graph's structure actually changes.
|
||||||
|
const basePositions = useMemo(
|
||||||
|
() =>
|
||||||
|
layout(
|
||||||
|
nodes.map((n) => n.id),
|
||||||
|
edges,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
),
|
||||||
|
[nodes, edges, width, height],
|
||||||
|
);
|
||||||
|
/** Manual node drags overlay the computed layout. */
|
||||||
|
const [moved, setMoved] = useState<Map<string, { x: number; y: number }>>(new Map());
|
||||||
|
const [view, setView] = useState({ k: 1, tx: 0, ty: 0 });
|
||||||
|
const drag = useRef<
|
||||||
|
| { kind: 'pan'; startX: number; startY: number; tx: number; ty: number }
|
||||||
|
| { kind: 'node'; id: string; startX: number; startY: number; x: number; y: number }
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
const dragged = useRef(false);
|
||||||
|
|
||||||
|
const positionOf = (id: string): { x: number; y: number } =>
|
||||||
|
moved.get(id) ?? basePositions.get(id) ?? { x: 0, y: 0 };
|
||||||
|
|
||||||
|
function onWheel(event: React.WheelEvent<SVGSVGElement>): void {
|
||||||
|
const factor = Math.exp(-event.deltaY * 0.002);
|
||||||
|
setView((prev) => {
|
||||||
|
const k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, prev.k * factor));
|
||||||
|
return { ...prev, k };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: React.PointerEvent<SVGSVGElement>): void {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
dragged.current = false;
|
||||||
|
const nodeId = (event.target as Element)
|
||||||
|
.closest('[data-node-id]')
|
||||||
|
?.getAttribute('data-node-id');
|
||||||
|
if (nodeId) {
|
||||||
|
const pos = positionOf(nodeId);
|
||||||
|
drag.current = {
|
||||||
|
kind: 'node',
|
||||||
|
id: nodeId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
...pos,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
drag.current = {
|
||||||
|
kind: 'pan',
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
tx: view.tx,
|
||||||
|
ty: view.ty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: React.PointerEvent<SVGSVGElement>): void {
|
||||||
|
const current = drag.current;
|
||||||
|
if (!current) return;
|
||||||
|
const dx = event.clientX - current.startX;
|
||||||
|
const dy = event.clientY - current.startY;
|
||||||
|
if (Math.abs(dx) + Math.abs(dy) > 3) dragged.current = true;
|
||||||
|
if (current.kind === 'pan') {
|
||||||
|
setView((prev) => ({ ...prev, tx: current.tx + dx, ty: current.ty + dy }));
|
||||||
|
} else {
|
||||||
|
const next = { x: current.x + dx / view.k, y: current.y + dy / view.k };
|
||||||
|
setMoved((prev) => new Map(prev).set(current.id, next));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event: React.PointerEvent<SVGSVGElement>): void {
|
||||||
|
const current = drag.current;
|
||||||
|
drag.current = null;
|
||||||
|
// A click (no real drag) on a node opens it.
|
||||||
|
if (current?.kind === 'node' && !dragged.current) onNodeClick?.(current.id);
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="force-graph"
|
||||||
|
viewBox={`${-width / 2} ${-height / 2} ${width} ${height}`}
|
||||||
|
role="img"
|
||||||
|
onWheel={onWheel}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
>
|
||||||
|
<g transform={`translate(${view.tx} ${view.ty}) scale(${view.k})`}>
|
||||||
|
{edges.map((edge) => {
|
||||||
|
const from = positionOf(edge.from);
|
||||||
|
const to = positionOf(edge.to);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`${edge.from}→${edge.to}`}
|
||||||
|
className="force-graph__edge"
|
||||||
|
x1={from.x}
|
||||||
|
y1={from.y}
|
||||||
|
x2={to.x}
|
||||||
|
y2={to.y}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const pos = positionOf(node.id);
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
className={
|
||||||
|
node.dashed ? 'force-graph__node force-graph__node--phantom' : 'force-graph__node'
|
||||||
|
}
|
||||||
|
transform={`translate(${pos.x} ${pos.y})`}
|
||||||
|
data-node-id={node.id}
|
||||||
|
data-testid={node.testId}
|
||||||
|
>
|
||||||
|
{node.highlight && <circle className="force-graph__ring" r={13} />}
|
||||||
|
<circle
|
||||||
|
className="force-graph__dot"
|
||||||
|
r={8}
|
||||||
|
fill={node.dashed ? 'transparent' : node.color}
|
||||||
|
stroke={node.color}
|
||||||
|
/>
|
||||||
|
<text className="force-graph__label" y={22} textAnchor="middle">
|
||||||
|
{node.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
apps/web/src/graph/PondGraphPage.tsx
Normal file
151
apps/web/src/graph/PondGraphPage.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import type { PageView, PondGraphView, PondView } from '@dorfteich/shared';
|
||||||
|
import { DEFAULT_LABEL_COLOR } from '@dorfteich/shared';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { FormError } from '../components/forms';
|
||||||
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
|
import { apiGet, apiPost } from '../lib/api';
|
||||||
|
import { ForceGraph, type ForceGraphEdge, type ForceGraphNode } from './ForceGraph';
|
||||||
|
|
||||||
|
/** Above this the layout cost stops being worth it — show a notice instead
|
||||||
|
* (issue #112; ponds do not realistically reach it). */
|
||||||
|
const MAX_GRAPH_NODES = 500;
|
||||||
|
|
||||||
|
const PHANTOM_PREFIX = 'phantom:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pond's knowledge graph (issue #112): readable pages as nodes, resolved
|
||||||
|
* wikilinks as edges, phantom targets as dashed nodes (click → create the
|
||||||
|
* page, which resolves the links, #47). Nodes are colored by their first
|
||||||
|
* label; the legend lists the colors in use.
|
||||||
|
*/
|
||||||
|
export function PondGraphPage(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('graph');
|
||||||
|
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const pond = useQuery({
|
||||||
|
queryKey: ['pond', pondSlug],
|
||||||
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
||||||
|
});
|
||||||
|
const graph = useQuery({
|
||||||
|
queryKey: ['pond-links', pond.data?.id],
|
||||||
|
queryFn: () => apiGet<PondGraphView>(`/ponds/${pond.data!.id}/links`),
|
||||||
|
enabled: Boolean(pond.data),
|
||||||
|
});
|
||||||
|
const { byId } = usePondLabels(pond.data?.id);
|
||||||
|
|
||||||
|
const slugById = useMemo(
|
||||||
|
() => new Map((graph.data?.nodes ?? []).map((node) => [node.id, node.slug])),
|
||||||
|
[graph.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { nodes, edges, legend } = useMemo(() => {
|
||||||
|
const data = graph.data;
|
||||||
|
if (!data) {
|
||||||
|
return { nodes: [] as ForceGraphNode[], edges: [] as ForceGraphEdge[], legend: [] };
|
||||||
|
}
|
||||||
|
const usedLabelIds = new Set<string>();
|
||||||
|
let hasUnlabeled = false;
|
||||||
|
const nodes: ForceGraphNode[] = data.nodes.map((node) => {
|
||||||
|
const label = node.labelIds.map((id) => byId.get(id)).find(Boolean);
|
||||||
|
if (label) usedLabelIds.add(label.id);
|
||||||
|
else hasUnlabeled = true;
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
label: node.title,
|
||||||
|
color: label?.color ?? DEFAULT_LABEL_COLOR,
|
||||||
|
testId: `graph-node-${node.slug}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const edges: ForceGraphEdge[] = data.edges.map((edge) => ({
|
||||||
|
from: edge.from,
|
||||||
|
to: edge.to,
|
||||||
|
}));
|
||||||
|
for (const phantom of data.phantoms) {
|
||||||
|
const id = `${PHANTOM_PREFIX}${phantom.targetSlug}`;
|
||||||
|
nodes.push({
|
||||||
|
id,
|
||||||
|
label: phantom.targetSlug,
|
||||||
|
color: DEFAULT_LABEL_COLOR,
|
||||||
|
dashed: true,
|
||||||
|
testId: `graph-phantom-${phantom.targetSlug}`,
|
||||||
|
});
|
||||||
|
for (const referrer of phantom.referencedBy) edges.push({ from: referrer, to: id });
|
||||||
|
}
|
||||||
|
const legend = [...usedLabelIds]
|
||||||
|
.map((id) => byId.get(id)!)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((label) => ({ name: label.name, color: label.color }));
|
||||||
|
if (hasUnlabeled) legend.push({ name: t('unlabeled'), color: DEFAULT_LABEL_COLOR });
|
||||||
|
return { nodes, edges, legend };
|
||||||
|
}, [graph.data, byId, t]);
|
||||||
|
|
||||||
|
async function onNodeClick(id: string): Promise<void> {
|
||||||
|
if (id.startsWith(PHANTOM_PREFIX)) {
|
||||||
|
const slug = id.slice(PHANTOM_PREFIX.length);
|
||||||
|
if (!window.confirm(t('createPhantom', { slug }))) return;
|
||||||
|
// Title = the slug, so the generated slug matches and the links resolve.
|
||||||
|
const page = await apiPost<PageView>(`/ponds/${pond.data!.id}/pages`, { title: slug });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['pond-links', pond.data!.id] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['pages', pond.data!.id] });
|
||||||
|
navigate(`/p/${pondSlug}/${page.slug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slug = slugById.get(id);
|
||||||
|
if (slug) navigate(`/p/${pondSlug}/${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pond.error) return <FormError error={pond.error} />;
|
||||||
|
if (graph.error) return <FormError error={graph.error} />;
|
||||||
|
if (!pond.data || !graph.data) return <></>;
|
||||||
|
|
||||||
|
const pageCount = graph.data.nodes.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="graph-page">
|
||||||
|
<header className="graph-page__header">
|
||||||
|
<h1>
|
||||||
|
{t('title')} — {pond.data.name}
|
||||||
|
</h1>
|
||||||
|
<p className="graph-page__counts">
|
||||||
|
{t('counts', { pages: pageCount, links: graph.data.edges.length })}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
{pageCount === 0 ? (
|
||||||
|
<p className="graph-page__hint">{t('empty')}</p>
|
||||||
|
) : pageCount > MAX_GRAPH_NODES ? (
|
||||||
|
<p className="graph-page__hint">{t('tooLarge', { max: MAX_GRAPH_NODES })}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="graph-page__hint">{t('hint')}</p>
|
||||||
|
<div className="graph-page__canvas">
|
||||||
|
<ForceGraph nodes={nodes} edges={edges} onNodeClick={(id) => void onNodeClick(id)} />
|
||||||
|
</div>
|
||||||
|
<footer className="graph-legend" aria-label={t('legend')}>
|
||||||
|
{legend.map((entry) => (
|
||||||
|
<span key={entry.name} className="graph-legend__entry">
|
||||||
|
<span
|
||||||
|
className="label-chip__swatch"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{graph.data.phantoms.length > 0 && (
|
||||||
|
<span className="graph-legend__entry graph-legend__entry--phantom">
|
||||||
|
<span className="graph-legend__phantom-swatch" aria-hidden />
|
||||||
|
{t('phantomLegend')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
|||||||
import deExport from '@dorfteich/shared/i18n/de/export.json';
|
import deExport from '@dorfteich/shared/i18n/de/export.json';
|
||||||
import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
||||||
import deFont from '@dorfteich/shared/i18n/de/font.json';
|
import deFont from '@dorfteich/shared/i18n/de/font.json';
|
||||||
|
import deGraph from '@dorfteich/shared/i18n/de/graph.json';
|
||||||
import deImport from '@dorfteich/shared/i18n/de/import.json';
|
import deImport from '@dorfteich/shared/i18n/de/import.json';
|
||||||
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
||||||
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
import deLegal from '@dorfteich/shared/i18n/de/legal.json';
|
||||||
@ -32,6 +33,7 @@ import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
|||||||
import enExport from '@dorfteich/shared/i18n/en/export.json';
|
import enExport from '@dorfteich/shared/i18n/en/export.json';
|
||||||
import enFiles from '@dorfteich/shared/i18n/en/files.json';
|
import enFiles from '@dorfteich/shared/i18n/en/files.json';
|
||||||
import enFont from '@dorfteich/shared/i18n/en/font.json';
|
import enFont from '@dorfteich/shared/i18n/en/font.json';
|
||||||
|
import enGraph from '@dorfteich/shared/i18n/en/graph.json';
|
||||||
import enImport from '@dorfteich/shared/i18n/en/import.json';
|
import enImport from '@dorfteich/shared/i18n/en/import.json';
|
||||||
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
||||||
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
||||||
@ -74,6 +76,7 @@ void i18n
|
|||||||
export: enExport,
|
export: enExport,
|
||||||
files: enFiles,
|
files: enFiles,
|
||||||
font: enFont,
|
font: enFont,
|
||||||
|
graph: enGraph,
|
||||||
import: enImport,
|
import: enImport,
|
||||||
labels: enLabels,
|
labels: enLabels,
|
||||||
legal: enLegal,
|
legal: enLegal,
|
||||||
@ -101,6 +104,7 @@ void i18n
|
|||||||
export: deExport,
|
export: deExport,
|
||||||
files: deFiles,
|
files: deFiles,
|
||||||
font: deFont,
|
font: deFont,
|
||||||
|
graph: deGraph,
|
||||||
import: deImport,
|
import: deImport,
|
||||||
labels: deLabels,
|
labels: deLabels,
|
||||||
legal: deLegal,
|
legal: deLegal,
|
||||||
|
|||||||
@ -357,15 +357,19 @@ function SidebarContent({
|
|||||||
{announcement}
|
{announcement}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* The trash stays a text link, pinned to the sidebar's bottom
|
{/* Graph + trash stay text links, pinned to the sidebar's bottom
|
||||||
(M10 follow-up; pond settings moved to the TopBar gear icon). */}
|
(M10 follow-up; pond settings moved to the TopBar gear icon).
|
||||||
{isOwner && (
|
The graph is for every member (#112); the trash is owner-only. */}
|
||||||
<div className="sidebar__footer">
|
<div className="sidebar__footer">
|
||||||
|
<Link to={`/p/${pondSlug}/graph`} className="linklike sidebar__graph-link">
|
||||||
|
{t('graph:link')}
|
||||||
|
</Link>
|
||||||
|
{isOwner && (
|
||||||
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
|
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
|
||||||
{t('editor:trash.link')}
|
{t('editor:trash.link')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1728,6 +1728,104 @@ button {
|
|||||||
.sidebar__footer {
|
.sidebar__footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: var(--space-4);
|
padding-top: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__graph-link {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Knowledge graph (issue #112) + local graph panel (#113). */
|
||||||
|
.graph-page__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-page__counts {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-page__hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-page__canvas {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-graph {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-graph__edge {
|
||||||
|
stroke: var(--color-border);
|
||||||
|
stroke-width: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-graph__node {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-graph__dot {
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-graph__node--phantom .force-graph__dot {
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-graph__node--phantom .force-graph__label {
|
||||||
|
fill: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-graph__ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color-accent);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-graph__label {
|
||||||
|
font-size: 11px;
|
||||||
|
fill: var(--color-text);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-legend__entry {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-legend__phantom-swatch {
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px dashed var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pond settings page + label manager. */
|
/* Pond settings page + label manager. */
|
||||||
|
|||||||
15
packages/shared/i18n/de/graph.json
Normal file
15
packages/shared/i18n/de/graph.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"title": "Wissensgraph",
|
||||||
|
"link": "Graph",
|
||||||
|
"hint": "Klick öffnet die Seite, Ziehen ordnet Knoten an, Scrollen zoomt.",
|
||||||
|
"counts": "{{pages}} Seiten · {{links}} Verknüpfungen",
|
||||||
|
"empty": "Noch keine Seiten — der Graph erscheint, sobald Seiten aufeinander verweisen.",
|
||||||
|
"tooLarge": "Dieser Teich hat mehr als {{max}} Seiten — die Graph-Ansicht ist begrenzt.",
|
||||||
|
"legend": "Labels",
|
||||||
|
"unlabeled": "Ohne Label",
|
||||||
|
"phantomLegend": "Fehlende Seite (Wikilink-Ziel)",
|
||||||
|
"createPhantom": "Seite „{{slug}}“ anlegen? Die darauf zeigenden Wikilinks werden aufgelöst.",
|
||||||
|
"localTitle": "Lokaler Graph",
|
||||||
|
"localDepth1": "Direkte Nachbarn",
|
||||||
|
"localDepth2": "Zwei Ebenen"
|
||||||
|
}
|
||||||
15
packages/shared/i18n/en/graph.json
Normal file
15
packages/shared/i18n/en/graph.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"title": "Knowledge graph",
|
||||||
|
"link": "Graph",
|
||||||
|
"hint": "Click a page to open it, drag nodes to rearrange, scroll to zoom.",
|
||||||
|
"counts": "{{pages}} pages · {{links}} links",
|
||||||
|
"empty": "No pages yet — the graph appears once pages link to each other.",
|
||||||
|
"tooLarge": "This pond has more than {{max}} pages — the graph view is capped.",
|
||||||
|
"legend": "Labels",
|
||||||
|
"unlabeled": "No label",
|
||||||
|
"phantomLegend": "Missing page (wikilink target)",
|
||||||
|
"createPhantom": "Create the page “{{slug}}”? The wikilinks pointing at it will resolve.",
|
||||||
|
"localTitle": "Local graph",
|
||||||
|
"localDepth1": "Direct neighbors",
|
||||||
|
"localDepth2": "Two hops"
|
||||||
|
}
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@ -287,6 +287,9 @@ importers:
|
|||||||
'@tiptap/react':
|
'@tiptap/react':
|
||||||
specifier: ^3.27.1
|
specifier: ^3.27.1
|
||||||
version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
d3-force:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.3.4
|
specifier: ^26.3.4
|
||||||
version: 26.3.4(typescript@5.9.3)
|
version: 26.3.4(typescript@5.9.3)
|
||||||
@ -333,6 +336,9 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.61.1
|
specifier: ^1.61.1
|
||||||
version: 1.61.1
|
version: 1.61.1
|
||||||
|
'@types/d3-force':
|
||||||
|
specifier: ^3.0.10
|
||||||
|
version: 3.0.10
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user