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

/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:
Claude Fable 5 2026-07-14 10:59:07 +02:00
parent ffcc337ed0
commit dbbe229cb9
10 changed files with 531 additions and 6 deletions

View File

@ -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",

View File

@ -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 />}>

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

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

View File

@ -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,

View File

@ -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>
</> </>
); );
} }

View File

@ -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. */

View 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"
}

View 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
View File

@ -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