|
1 | 1 | 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'; |
3 | 14 |
|
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 | | - */ |
26 | 15 | 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; |
31 | 20 |
|
32 | 21 | constructor( |
33 | 22 | private readonly svgElement: SVGSVGElement, |
34 | | - private readonly onNodeClick: (id: string | null) => void |
| 23 | + private readonly onNodeClick: (id: string | null) => void, |
35 | 24 | ) { |
36 | 25 | this.svg = d3.select(svgElement); |
37 | | - |
38 | | - // Root group — all graph elements live here so zoom/pan transforms apply |
39 | 26 | 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); |
56 | 29 | } |
57 | 30 |
|
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 | | - */ |
62 | 31 | render(data: GraphData, visibleNodeIds: Set<string>): void { |
63 | | - // Stop any running simulation |
64 | 32 | this.simulation?.stop(); |
65 | | - |
66 | | - // Clear previous render |
67 | 33 | this.root.selectAll('*').remove(); |
68 | 34 |
|
69 | 35 | const width = this.svgElement.clientWidth || 800; |
70 | 36 | 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), |
78 | 49 | ); |
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 | | - }); |
192 | 50 | } |
193 | 51 |
|
194 | | - /** |
195 | | - * Highlights the selected node and dims unconnected nodes/edges. |
196 | | - * Requirement 3.1, 3.3 |
197 | | - */ |
198 | 52 | 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); |
234 | 54 | } |
235 | 55 |
|
236 | | - /** |
237 | | - * Shows/hides nodes and their connected edges based on the visibility set. |
238 | | - * Requirement 4.2 |
239 | | - */ |
240 | 56 | 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); |
248 | 58 | } |
249 | 59 |
|
250 | | - /** Stops simulation and removes all event listeners. */ |
251 | 60 | destroy(): void { |
252 | 61 | this.simulation?.stop(); |
253 | 62 | this.simulation = null; |
254 | 63 | this.svg.on('.zoom', null); |
255 | 64 | this.svg.on('click', null); |
256 | 65 | this.root.selectAll('*').remove(); |
257 | 66 | } |
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 | | - } |
263 | 67 | } |
0 commit comments