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/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.24.0",
|
||||
@ -42,6 +43,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.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 { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
|
||||
import { PondGraphPage } from './graph/PondGraphPage';
|
||||
import { MAINTENANCE_EVENT } from './lib/api';
|
||||
import { MaintenancePage } from './pages/MaintenancePage';
|
||||
import { AppLayout } from './layout/AppLayout';
|
||||
@ -90,6 +91,8 @@ export function App(): React.JSX.Element {
|
||||
<Route path="p/:pondSlug/trash" element={<TrashPage />} />
|
||||
{/* Static "settings" wins over :pageSlug, like "trash" above. */}
|
||||
<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>
|
||||
<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 deFiles from '@dorfteich/shared/i18n/de/files.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 deLabels from '@dorfteich/shared/i18n/de/labels.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 enFiles from '@dorfteich/shared/i18n/en/files.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 enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
||||
import enLegal from '@dorfteich/shared/i18n/en/legal.json';
|
||||
@ -74,6 +76,7 @@ void i18n
|
||||
export: enExport,
|
||||
files: enFiles,
|
||||
font: enFont,
|
||||
graph: enGraph,
|
||||
import: enImport,
|
||||
labels: enLabels,
|
||||
legal: enLegal,
|
||||
@ -101,6 +104,7 @@ void i18n
|
||||
export: deExport,
|
||||
files: deFiles,
|
||||
font: deFont,
|
||||
graph: deGraph,
|
||||
import: deImport,
|
||||
labels: deLabels,
|
||||
legal: deLegal,
|
||||
|
||||
@ -357,15 +357,19 @@ function SidebarContent({
|
||||
{announcement}
|
||||
</p>
|
||||
|
||||
{/* The trash stays a text link, pinned to the sidebar's bottom
|
||||
(M10 follow-up; pond settings moved to the TopBar gear icon). */}
|
||||
{isOwner && (
|
||||
<div className="sidebar__footer">
|
||||
{/* Graph + trash stay text links, pinned to the sidebar's bottom
|
||||
(M10 follow-up; pond settings moved to the TopBar gear icon).
|
||||
The graph is for every member (#112); the trash is owner-only. */}
|
||||
<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">
|
||||
{t('editor:trash.link')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1728,6 +1728,104 @@ button {
|
||||
.sidebar__footer {
|
||||
margin-top: auto;
|
||||
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. */
|
||||
|
||||
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':
|
||||
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)
|
||||
d3-force:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
i18next:
|
||||
specifier: ^26.3.4
|
||||
version: 26.3.4(typescript@5.9.3)
|
||||
@ -333,6 +336,9 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
'@types/d3-force':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10
|
||||
'@types/react':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.17
|
||||
|
||||
Loading…
Reference in New Issue
Block a user