Skip to content

Commit 2d70f02

Browse files
refactor(wiki-graph): polish code, remove comments, add readme
1 parent 6c91c60 commit 2d70f02

8 files changed

Lines changed: 346 additions & 229 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# D3 Graph Renderer
2+
3+
This directory contains the D3.js interactive force-directed graph engine for the **Wiki Graph** application. It renders entities, concepts, and **sources** as an interactive node-edge network diagram.
4+
5+
---
6+
7+
## 💡 Key Features & Functionality
8+
9+
- **Interactive Force-Directed Layout**
10+
- Uses physics-based simulation (repulsion, link strength, collision avoidance).
11+
- Drag-and-drop nodes to explore connections interactively.
12+
- Dynamically sizes nodes based on their number of connections.
13+
14+
- **Visual Encoding & Styling**
15+
- **Color-coded by type**: Entities (`orange`), Concepts (`cyan`), and Sources (`green`).
16+
- **Ghost nodes**: Displayed with dashed borders for uncreated or referenced entities.
17+
- **Directed arrows**: Indicate clear reference directions between items.
18+
19+
- **Navigation & Exploration**
20+
- **Zoom & Pan**: Smooth viewport zooming (0.1x to 8x) and canvas panning.
21+
- **Node Highlight & Focus**: Selecting a node highlights its direct connections while dimming unrelated elements.
22+
- **Visibility Filtering**: Easily show or hide subsets of nodes without resetting the layout state.
23+
24+
- **Accessibility**
25+
- Full keyboard navigation support (`Enter` / `Space` to select nodes).
26+
- ARIA attributes on SVG elements for screen readers.
27+
28+
---
29+
30+
## 📁 Module Summary
31+
32+
| File | Primary Function |
33+
| --- | --- |
34+
| [`d3-force-renderer.ts`](./d3-force-renderer.ts) | Main entry class (`D3ForceRenderer`) coordinating rendering, simulation lifecycle, and updates. |
35+
| [`force-simulation.ts`](./force-simulation.ts) | Configures D3 force simulation (charge, collision, links) and drag controls. |
36+
| [`graph-svg.ts`](./graph-svg.ts) | Handles SVG DOM generation for nodes, edges, arrow markers, and zoom behaviors. |
37+
| [`graph-state.ts`](./graph-state.ts) | Manages selection highlights, dimming effect, and visibility state updates. |
38+
| [`graph-data.ts`](./graph-data.ts) | Filters raw graph data into renderable nodes and links. |
39+
| [`graph-style.ts`](./graph-style.ts) | Visual constants (type color schemes, radii limits, dimming opacity). |
40+
| [`renderer.types.ts`](./renderer.types.ts) | TypeScript type aliases for D3 selections and simulation objects. |
Lines changed: 33 additions & 229 deletions
Original file line numberDiff line numberDiff line change
@@ -1,263 +1,67 @@
11
import * as d3 from 'd3';
2-
import type { GraphData, GraphNode, SimulationNode } from '../models/graph.models';
2+
import type { GraphData } from '../models/graph.models';
3+
import { createVisibleGraph } from './graph-data';
4+
import { attachNodeDrag, createSimulation, updateSimulationPositions } from './force-simulation';
5+
import {
6+
attachSvgInteractions,
7+
createZoom,
8+
renderArrowMarker,
9+
renderEdges,
10+
renderNodes,
11+
} from './graph-svg';
12+
import { updateSelection, updateVisibility } from './graph-state';
13+
import type { ForceSimulation, RootSelection, SvgSelection } from './renderer.types';
314

4-
/** Internal edge type used by the D3 simulation — uses source/target node references. */
5-
interface SimEdge extends d3.SimulationLinkDatum<SimulationNode> {
6-
source: SimulationNode;
7-
target: SimulationNode;
8-
}
9-
10-
/** Fill colors per node type — Requirement 2.3 */
11-
const TYPE_COLORS: Record<string, string> = {
12-
entity: '#F5A623', // warm amber
13-
concept: '#00BCD4', // vivid cyan
14-
source: '#50C878', // medium green (unchanged)
15-
};
16-
17-
const BASE_RADIUS = 6;
18-
const MAX_RADIUS = 24;
19-
const DIM_OPACITY = 0.15;
20-
21-
/**
22-
* Plain TypeScript class that owns all D3 logic.
23-
* Angular components never manipulate SVG nodes directly.
24-
* Requirements: 2.1–2.9, 3.1, 3.3
25-
*/
2615
export class D3ForceRenderer {
27-
private readonly svg: d3.Selection<SVGSVGElement, unknown, null, undefined>;
28-
private readonly root: d3.Selection<SVGGElement, unknown, null, undefined>;
29-
private simulation: d3.Simulation<SimulationNode, SimEdge> | null = null;
30-
private zoom: d3.ZoomBehavior<SVGSVGElement, unknown>;
16+
private readonly svg: SvgSelection;
17+
private readonly root: RootSelection;
18+
private readonly zoom: d3.ZoomBehavior<SVGSVGElement, unknown>;
19+
private simulation: ForceSimulation | null = null;
3120

3221
constructor(
3322
private readonly svgElement: SVGSVGElement,
34-
private readonly onNodeClick: (id: string | null) => void
23+
private readonly onNodeClick: (id: string | null) => void,
3524
) {
3625
this.svg = d3.select(svgElement);
37-
38-
// Root group — all graph elements live here so zoom/pan transforms apply
3926
this.root = this.svg.append('g').attr('class', 'graph-root');
40-
41-
// Zoom & pan — Requirements 2.7, 2.8
42-
this.zoom = d3.zoom<SVGSVGElement, unknown>()
43-
.scaleExtent([0.1, 8])
44-
.on('zoom', (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
45-
this.root.attr('transform', event.transform.toString());
46-
});
47-
48-
this.svg
49-
.call(this.zoom)
50-
// Background click deselects — Requirement 3.4
51-
.on('click', (event: MouseEvent) => {
52-
if (event.target === svgElement || (event.target as Element).tagName === 'svg') {
53-
this.onNodeClick(null);
54-
}
55-
});
27+
this.zoom = createZoom(this.root);
28+
attachSvgInteractions(this.svg, svgElement, this.zoom, onNodeClick);
5629
}
5730

58-
/**
59-
* (Re)renders the graph with the given data and visibility set.
60-
* Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.9
61-
*/
6231
render(data: GraphData, visibleNodeIds: Set<string>): void {
63-
// Stop any running simulation
6432
this.simulation?.stop();
65-
66-
// Clear previous render
6733
this.root.selectAll('*').remove();
6834

6935
const width = this.svgElement.clientWidth || 800;
7036
const height = this.svgElement.clientHeight || 600;
71-
72-
const allNodes = Array.from(data.nodes.values()) as SimulationNode[];
73-
const visibleNodes = allNodes.filter(n => visibleNodeIds.size === 0 || visibleNodeIds.has(n.id));
74-
75-
const visibleIds = new Set(visibleNodes.map(n => n.id));
76-
const visibleEdges = data.edges.filter(
77-
e => visibleIds.has(e.sourceId) && visibleIds.has(e.targetId)
37+
const graph = createVisibleGraph(data, visibleNodeIds);
38+
const edges = renderEdges(this.root, graph.edges);
39+
renderArrowMarker(this.svg);
40+
const nodes = renderNodes(this.root, graph.nodes, this.onNodeClick);
41+
42+
attachNodeDrag(nodes, () => this.simulation);
43+
this.simulation = createSimulation(
44+
graph.nodes,
45+
graph.edges,
46+
width,
47+
height,
48+
() => updateSimulationPositions(edges, nodes),
7849
);
79-
80-
// Build edge objects D3 can use (source/target as node references)
81-
const nodeById = new Map(visibleNodes.map(n => [n.id, n]));
82-
const simEdges: SimEdge[] = visibleEdges
83-
.map(e => ({ source: nodeById.get(e.sourceId)!, target: nodeById.get(e.targetId)! }))
84-
.filter((e): e is SimEdge => !!e.source && !!e.target);
85-
86-
// Edges layer
87-
const edgeGroup = this.root.append('g').attr('class', 'edges');
88-
const edgeSel = edgeGroup
89-
.selectAll<SVGLineElement, SimEdge>('line')
90-
.data(simEdges)
91-
.join('line')
92-
.attr('class', 'edge')
93-
.attr('stroke', '#585b70')
94-
.attr('stroke-width', 1)
95-
.attr('stroke-opacity', 0.6)
96-
.attr('marker-end', 'url(#arrow)');
97-
98-
// Arrow marker
99-
this.svg.select('defs').remove();
100-
this.svg.append('defs').append('marker')
101-
.attr('id', 'arrow')
102-
.attr('viewBox', '0 -5 10 10')
103-
.attr('refX', 18)
104-
.attr('refY', 0)
105-
.attr('markerWidth', 6)
106-
.attr('markerHeight', 6)
107-
.attr('orient', 'auto')
108-
.append('path')
109-
.attr('d', 'M0,-5L10,0L0,5')
110-
.attr('fill', '#585b70');
111-
112-
// Nodes layer
113-
const nodeGroup = this.root.append('g').attr('class', 'nodes');
114-
const nodeSel = nodeGroup
115-
.selectAll<SVGGElement, SimulationNode>('g.node')
116-
.data(visibleNodes, d => d.id)
117-
.join('g')
118-
.attr('class', 'node')
119-
.attr('role', 'button')
120-
.attr('tabindex', '0')
121-
.attr('aria-label', d => `${d.title} (${d.type})`)
122-
.style('cursor', 'pointer');
123-
124-
// Circle — Requirement 2.3, 2.4, 2.9
125-
nodeSel.append('circle')
126-
.attr('r', d => this.nodeRadius(d))
127-
.attr('fill', d => d.isGhost ? 'none' : (TYPE_COLORS[d.type] ?? '#888'))
128-
.attr('stroke', d => d.isGhost ? (TYPE_COLORS[d.type] ?? '#888') : 'none')
129-
.attr('stroke-width', d => d.isGhost ? 2 : 0)
130-
.attr('stroke-dasharray', d => d.isGhost ? '4 2' : 'none')
131-
.attr('opacity', d => d.isGhost ? 0.4 : 1);
132-
133-
// Label — Requirement 2.5
134-
nodeSel.append('text')
135-
.attr('dy', d => this.nodeRadius(d) + 12)
136-
.attr('text-anchor', 'middle')
137-
.attr('font-size', '10px')
138-
.attr('fill', '#a6adc8')
139-
.attr('pointer-events', 'none')
140-
.text(d => d.title.length > 20 ? d.title.slice(0, 18) + '…' : d.title);
141-
142-
// Click handler — Requirement 3.1
143-
nodeSel.on('click', (event: MouseEvent, d: SimulationNode) => {
144-
event.stopPropagation();
145-
this.onNodeClick(d.id);
146-
});
147-
148-
// Keyboard activation
149-
nodeSel.on('keydown', (event: KeyboardEvent, d: SimulationNode) => {
150-
if (event.key === 'Enter' || event.key === ' ') {
151-
event.preventDefault();
152-
this.onNodeClick(d.id);
153-
}
154-
});
155-
156-
// Drag — Requirement 2.6
157-
const drag = d3.drag<SVGGElement, SimulationNode>()
158-
.on('start', (event, d) => {
159-
if (!event.active) this.simulation?.alphaTarget(0.3).restart();
160-
d.fx = d.x;
161-
d.fy = d.y;
162-
})
163-
.on('drag', (event, d) => {
164-
d.fx = event.x;
165-
d.fy = event.y;
166-
})
167-
.on('end', (event, d) => {
168-
if (!event.active) this.simulation?.alphaTarget(0);
169-
d.fx = null;
170-
d.fy = null;
171-
});
172-
173-
nodeSel.call(drag);
174-
175-
// Force simulation — Requirement 2.1
176-
this.simulation = d3.forceSimulation<SimulationNode, SimEdge>(visibleNodes)
177-
.force('link', d3.forceLink<SimulationNode, SimEdge>(simEdges)
178-
.id(d => d.id)
179-
.distance(80))
180-
.force('charge', d3.forceManyBody().strength(-200))
181-
.force('center', d3.forceCenter(width / 2, height / 2))
182-
.force('collision', d3.forceCollide<SimulationNode>().radius(d => this.nodeRadius(d) + 4))
183-
.on('tick', () => {
184-
edgeSel
185-
.attr('x1', d => d.source.x ?? 0)
186-
.attr('y1', d => d.source.y ?? 0)
187-
.attr('x2', d => d.target.x ?? 0)
188-
.attr('y2', d => d.target.y ?? 0);
189-
190-
nodeSel.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`);
191-
});
19250
}
19351

194-
/**
195-
* Highlights the selected node and dims unconnected nodes/edges.
196-
* Requirement 3.1, 3.3
197-
*/
19852
updateSelection(selectedId: string | null): void {
199-
if (!selectedId) {
200-
// Restore default state
201-
this.root.selectAll<SVGGElement, SimulationNode>('g.node')
202-
.style('opacity', null);
203-
this.root.selectAll<SVGLineElement, SimEdge>('line.edge')
204-
.style('opacity', null)
205-
.attr('stroke', '#585b70');
206-
this.root.selectAll<SVGCircleElement, SimulationNode>('g.node circle')
207-
.attr('stroke', d => d.isGhost ? (TYPE_COLORS[d.type] ?? '#888') : 'none')
208-
.attr('stroke-width', d => d.isGhost ? 2 : 0);
209-
return;
210-
}
211-
212-
// Collect connected node ids
213-
const connectedIds = new Set<string>([selectedId]);
214-
this.root.selectAll<SVGLineElement, SimEdge>('line.edge')
215-
.each(d => {
216-
if (d.source.id === selectedId) connectedIds.add(d.target.id);
217-
if (d.target.id === selectedId) connectedIds.add(d.source.id);
218-
});
219-
220-
// Dim unconnected nodes
221-
this.root.selectAll<SVGGElement, SimulationNode>('g.node')
222-
.style('opacity', d => connectedIds.has(d.id) ? null : String(DIM_OPACITY));
223-
224-
// Dim unconnected edges
225-
this.root.selectAll<SVGLineElement, SimEdge>('line.edge')
226-
.style('opacity', d =>
227-
d.source.id === selectedId || d.target.id === selectedId ? null : String(DIM_OPACITY)
228-
);
229-
230-
// Selection ring on selected node
231-
this.root.selectAll<SVGCircleElement, SimulationNode>('g.node circle')
232-
.attr('stroke', d => d.id === selectedId ? '#f5c2e7' : (d.isGhost ? (TYPE_COLORS[d.type] ?? '#888') : 'none'))
233-
.attr('stroke-width', d => d.id === selectedId ? 3 : (d.isGhost ? 2 : 0));
53+
updateSelection(this.root, selectedId);
23454
}
23555

236-
/**
237-
* Shows/hides nodes and their connected edges based on the visibility set.
238-
* Requirement 4.2
239-
*/
24056
updateVisibility(visibleNodeIds: Set<string>): void {
241-
this.root.selectAll<SVGGElement, SimulationNode>('g.node')
242-
.style('display', d => visibleNodeIds.has(d.id) ? null : 'none');
243-
244-
this.root.selectAll<SVGLineElement, SimEdge>('line.edge')
245-
.style('display', d =>
246-
visibleNodeIds.has(d.source.id) && visibleNodeIds.has(d.target.id) ? null : 'none'
247-
);
57+
updateVisibility(this.root, visibleNodeIds);
24858
}
24959

250-
/** Stops simulation and removes all event listeners. */
25160
destroy(): void {
25261
this.simulation?.stop();
25362
this.simulation = null;
25463
this.svg.on('.zoom', null);
25564
this.svg.on('click', null);
25665
this.root.selectAll('*').remove();
25766
}
258-
259-
private nodeRadius(node: GraphNode): number {
260-
const connections = node.inDegree + node.outDegree;
261-
return Math.min(MAX_RADIUS, BASE_RADIUS + Math.sqrt(connections) * 2);
262-
}
26367
}

0 commit comments

Comments
 (0)