1function renderGraph(containerId, graph, colorMap) {
2    let initialSimulationEnded = false;
3    const baseColors = {
4        page: '#1f77b4',
5        media: '#ff7f0e',
6        default: '#999999'
7    };
8
9    const container = document.getElementById(containerId);
10    const width = container.clientWidth;
11    const height = container.clientHeight;
12    const radius = 18;
13
14    const svg = d3.select('#' + containerId)
15        .append('svg')
16        .attr('width', width)
17        .attr('height', height);
18
19    const g = svg.append('g');
20
21    let currentTransform = d3.zoomIdentity;
22
23    const zoom = d3.zoom().on('zoom', event => {
24        currentTransform = event.transform;
25        g.attr('transform', currentTransform);
26        updateVisibility();
27    });
28
29    svg.call(zoom);
30
31    svg.append('defs').append('marker')
32        .attr('id', 'arrowhead')
33        .attr('viewBox', '-0 -5 10 10')
34        .attr('refX', 23)
35        .attr('refY', 0)
36        .attr('orient', 'auto')
37        .attr('markerWidth', 6)
38        .attr('markerHeight', 6)
39        .attr('xoverflow', 'visible')
40        .append('svg:path')
41        .attr('d', 'M 0,-5 L 10,0 L 0,5')
42        .attr('fill', '#999')
43        .style('stroke', 'none');
44
45    const defs = svg.append('defs');
46    const gradientCache = new Map();
47    let gradientCounter = 0;
48
49    graph.nodes.forEach((d) => {
50        d.namespace = extractNamespace(d.id);
51
52        const nsColor = getColorForNode(d, colorMap).toLowerCase();
53        const baseColor = (baseColors[d.group] || baseColors.default).toLowerCase();
54        const key = `${baseColor}_${nsColor}`;
55
56        if (!gradientCache.has(key)) {
57            const gradientId = `grad_${containerId}_${gradientCounter++}`;
58            const gradient = defs.append('radialGradient')
59                .attr('id', gradientId)
60                .attr('cx', '50%')
61                .attr('cy', '50%')
62                .attr('r', '50%');
63
64            gradient.append('stop')
65                .attr('offset', '80%')
66                .attr('stop-color', nsColor);
67
68            gradient.append('stop')
69                .attr('offset', '100%')
70                .attr('stop-color', baseColor);
71
72            gradientCache.set(key, gradientId);
73        }
74
75        d.gradientId = gradientCache.get(key);
76    });
77
78    const degreeMap = {};
79    graph.links.forEach(link => {
80        degreeMap[link.source] = (degreeMap[link.source] || 0) + 1;
81        degreeMap[link.target] = (degreeMap[link.target] || 0) + 1;
82    });
83
84    const simulation = d3.forceSimulation(graph.nodes)
85        .force('link', d3.forceLink(graph.links)
86            .id(d => d.id)
87            .distance(d => {
88                const sourceDegree = degreeMap[d.source.id] || 1;
89                const targetDegree = degreeMap[d.target.id] || 1;
90                const avgDegree = (sourceDegree + targetDegree) / 2;
91                return 100 + avgDegree * 10;
92            }))
93        .force('charge', d3.forceManyBody().strength(-250))
94        .force('center', d3.forceCenter(width / 2, height / 2))
95        .force('collision', d3.forceCollide().radius(radius + 10));
96
97    const link = g.selectAll('.link')
98        .data(graph.links)
99        .enter().append('path')
100        .attr('class', 'link')
101        .attr('fill', 'none')
102        .style('stroke-width', 1.5)
103        .style('stroke', '#999')
104        .attr('marker-end', 'url(#arrowhead)');
105
106    const node = g.selectAll('.node')
107        .data(graph.nodes)
108        .enter().append('circle')
109        .attr('class', 'node')
110        .attr('r', radius)
111        .style('fill', d => `url(#${d.gradientId})`)
112        .style('stroke', d => d.group === 'media' ? '#d62728' : 'none')
113        .style('stroke-width', d => d.group === 'media' ? 3 : 0)
114        .call(d3.drag()
115            .on('start', dragStarted)
116            .on('drag', dragged)
117            .on('end', dragEnded));
118
119    const label = g.selectAll('.label')
120        .data(graph.nodes)
121        .enter().append('text')
122        .attr('class', 'label')
123        .attr('text-anchor', 'middle')
124        .attr('dy', -25)
125        .style('font-size', '12px')
126        .text(d => d.label);
127
128    node.on('mouseover', function(event, d) {
129        node.style('opacity', o => (o === d ? 1 : 0.2));
130
131        link.style('stroke', l => (l.source === d || l.target === d ? '#d62728' : '#999'))
132            .style('stroke-width', l => (l.source === d || l.target === d ? 3 : 1.5))
133            .style('opacity', l => (l.source === d || l.target === d ? 1 : 0.2));
134
135        node.style('stroke', o => (o === d || graph.links.some(l => (l.source === d && l.target === o) || (l.target === d && l.source === o)) ? '#d62728' : 'none'))
136            .style('stroke-width', o => (o === d || graph.links.some(l => (l.source === d && l.target === o) || (l.target === d && l.source === o)) ? 3 : 0));
137    }).on('mouseout', function(event, d) {
138        node.style('opacity', 1)
139            .style('stroke', d => d.group === 'media' ? '#d62728' : 'none')
140            .style('stroke-width', d => d.group === 'media' ? 3 : 0);
141
142        link.style('stroke', '#999')
143            .style('stroke-width', 1.5)
144            .style('opacity', 1);
145    });
146
147    simulation.on('tick', () => {
148        link.attr('d', d => `M${d.source.x},${d.source.y} L${d.target.x},${d.target.y}`);
149        node.attr('cx', d => d.x).attr('cy', d => d.y);
150        label.attr('x', d => d.x).attr('y', d => d.y);
151        updateVisibility();
152    });
153
154    simulation.on('end', () => {
155        if (!initialSimulationEnded) {
156            fitGraphToViewport(svg, g, width, height);
157            initialSimulationEnded = true;
158        }
159    });
160
161    function dragStarted(event, d) {
162        if (!event.active) simulation.alphaTarget(0.3).restart();
163        d.fx = d.x;
164        d.fy = d.y;
165    }
166
167    function dragged(event, d) {
168        d.fx = event.x;
169        d.fy = event.y;
170    }
171
172    function dragEnded(event, d) {
173        if (!event.active) simulation.alphaTarget(0);
174        d.fx = null;
175        d.fy = null;
176    }
177
178    function fitGraphToViewport(svg, g, width, height) {
179        const bounds = g.node().getBBox();
180        const fullWidth = bounds.width;
181        const fullHeight = bounds.height;
182        const midX = bounds.x + fullWidth / 2;
183        const midY = bounds.y + fullHeight / 2;
184
185        if (fullWidth === 0 || fullHeight === 0) return;
186
187        const scale = 0.85 / Math.max(fullWidth / width, fullHeight / height);
188        const translate = [width / 2 - scale * midX, height / 2 - scale * midY];
189
190        currentTransform = d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale);
191
192        svg.transition()
193            .duration(750)
194            .call(zoom.transform, currentTransform)
195            .on('end', () => {
196                updateVisibility();
197            });
198    }
199
200    function isInsideViewport(x, y) {
201        return x >= 0 && x <= width && y >= 0 && y <= height;
202    }
203
204    function updateVisibility() {
205        node.style('visibility', d => {
206            const [x, y] = currentTransform.apply([d.x, d.y]);
207            return isInsideViewport(x, y) ? 'visible' : 'hidden';
208        });
209
210        label.style('visibility', d => {
211            const [x, y] = currentTransform.apply([d.x, d.y]);
212            return isInsideViewport(x, y) ? 'visible' : 'hidden';
213        });
214
215        link.style('visibility', d => {
216            const [sx, sy] = currentTransform.apply([d.source.x, d.source.y]);
217            const [tx, ty] = currentTransform.apply([d.target.x, d.target.y]);
218            return isInsideViewport(sx, sy) || isInsideViewport(tx, ty);
219        });
220    }
221
222    function extractNamespace(id) {
223        const parts = id.split(':');
224        parts.pop();
225        return parts.join(':');
226    }
227
228    function getColorForNode(d, colorMap) {
229        if (!d.namespace) return baseColors[d.group] || baseColors.default;
230        const parts = d.namespace.split(':');
231        while (parts.length > 0) {
232            const ns = parts.join(':');
233            if (colorMap[ns]) return colorMap[ns];
234            parts.pop();
235        }
236        return baseColors[d.group] || baseColors.default;
237    }
238}
239