import * as d3 from 'd3' import { Node, Relationship, GraphData } from './graph-data.js' export class GraphViewer { private svg: any private svgNodes: any private svgRelationships: any private nodes: Node[] = [] private relationships: Relationship[] = [] private needZoomFit = true private options: any private svgScale private svgTranslate private svgContainer: any private zoom: any public simulation: any constructor(_selector: any, _options: any) { this.options = { arrowSize: 4, colors: this.colors(), highlight: undefined, infoPanel: true, minCollision: undefined, graphData: undefined, dataUrl: undefined, nodeOutlineFillColor: undefined, nodeRadius: 25, relationshipColor: '#a5abb6', zoomFit: false, classes2colors: {}, numClasses: 0, ..._options } this.init(_selector) } private init(selector: any) { // SVG 컨테이너 생성 const svgContainer = d3 .select(selector) .append('svg') .attr('width', '100%') .attr('height', '100%') .attr('class', 'graph-viewer') // 줌 동작 정의 (마우스 중심 줌 자동 지원) const zoom = d3 .zoom() .scaleExtent([0.1, 10]) .on('zoom', event => { // D3가 자동으로 계산한 transform을 그대로 사용 (마우스 중심 줌) this.svg.attr('transform', event.transform) }) // SVG 컨테이너에 줌 적용 svgContainer.call(zoom) // 기본 더블클릭 줌 비활성화 svgContainer.on('dblclick.zoom', null) // 배경 더블클릭 시 zoomFit 실행 svgContainer.on('dblclick', event => { event.preventDefault() this.zoomFit() }) // 실제 그래프 내용을 담을 그룹 생성 this.svg = svgContainer.append('g').attr('class', 'graph-container').attr('width', '100%').attr('height', '100%') // SVG 컨테이너 참조 저장 (zoomFit에서 사용) this.svgContainer = svgContainer this.zoom = zoom // Define arrow markers for graph links this.svg .append('defs') .append('marker') .attr('id', 'arrow') .attr('viewBox', '0 -5 10 10') .attr('refX', 10) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') .attr('fill', this.options.relationshipColor) this.svgNodes = this.svg.append('g').attr('class', 'nodes') this.svgRelationships = this.svg.append('g').attr('class', 'relationships') this.simulation = this.initSimulation() if (this.options.graphData) { this.updateWithGraphData(this.options.graphData) } } initSimulation() { const x = this.svg.node().parentElement.parentElement.clientWidth / 2 const y = this.svg.node().parentElement.parentElement.clientHeight / 2 var simulation = d3 .forceSimulation() .force( 'collide', d3 .forceCollide() .radius(d => { return this.options.minCollision }) .iterations(2) ) .force('charge', d3.forceManyBody()) .force( 'link', d3.forceLink().id(d => { return d.id }) ) .force('center', d3.forceCenter(x, y)) .on('tick', () => { this.tick() }) .on('end', () => { // 자동 zoomFit 제거 - 더블클릭으로만 실행 }) return simulation } private tick() { this.svgNodes.selectAll('.node').attr('transform', (d: any) => `translate(${d.x}, ${d.y})`) this.svgRelationships .selectAll('.relationship') .attr('x1', (d: any) => this.calculateIntersection(d.source, d.target).x1) .attr('y1', (d: any) => this.calculateIntersection(d.source, d.target).y1) .attr('x2', (d: any) => this.calculateIntersection(d.source, d.target).x2) .attr('y2', (d: any) => this.calculateIntersection(d.source, d.target).y2) this.svgRelationships.selectAll('.relationship-text').attr('transform', (d: any) => { const midX = (this.calculateIntersection(d.source, d.target).x1 + this.calculateIntersection(d.source, d.target).x2) / 2 const midY = (this.calculateIntersection(d.source, d.target).y1 + this.calculateIntersection(d.source, d.target).y2) / 2 return `translate(${midX}, ${midY})` }) } private calculateIntersection(source, target) { const dx = target.x - source.x const dy = target.y - source.y const distance = Math.sqrt(dx * dx + dy * dy) const ratio = (distance - this.options.nodeRadius) / distance const x1 = source.x + dx * (this.options.nodeRadius / distance) const y1 = source.y + dy * (this.options.nodeRadius / distance) const x2 = target.x - dx * (this.options.nodeRadius / distance) const y2 = target.y - dy * (this.options.nodeRadius / distance) return { x1, y1, x2, y2 } } private colors() { return [ '#68bdf6', // light blue '#6dce9e', // green #1 '#faafc2', // light pink '#f2baf6', // purple '#ff928c', // light red '#fcea7e', // light yellow '#ffc766', // light orange '#405f9e', // navy blue '#a5abb6', // dark gray '#78cecb', // green #2, '#b88cbb', // dark purple '#ced2d9', // light gray '#e84646', // dark red '#fa5f86', // dark pink '#ffab1a', // dark orange '#fcda19', // dark yellow '#797b80', // black '#c9d96f', // pistachio '#47991f', // green #3 '#70edee', // turquoise '#ff75ea' // pink ] } updateWithGraphData(graphData: GraphData) { this.nodes = graphData.results[0].data[0].graph.nodes this.relationships = graphData.results[0].data[0].graph.relationships this.relationships.forEach(rel => { const sourceNode = this.nodes.find(node => node.id === rel.startNode) const targetNode = this.nodes.find(node => node.id === rel.endNode) if (!sourceNode || !targetNode) { console.warn(`Node not found for relationship: ${rel.id}`) return } rel.source = sourceNode rel.target = targetNode }) this.updateNodesAndRelationships() } private updateNodesAndRelationships() { this.needZoomFit = true this.svgNodes.selectAll('.node').remove() this.svgRelationships.selectAll('.relationship').remove() this.svgRelationships.selectAll('.relationship-text').remove() this.appendNodesToGraph() this.appendRelationshipsToGraph() this.simulation.nodes(this.nodes) ;(this.simulation.force('link') as d3.ForceLink).links(this.relationships) // 시뮬레이션을 강제로 재시작하여 레이아웃이 적절히 재정렬되도록 함 this.simulation.alpha(1).restart() } private appendNodesToGraph() { const nodeEnter = this.svgNodes .selectAll('.node') .data(this.nodes, (d: any) => d.id) .enter() .append('g') .attr('class', d => { var highlight, i, classes = 'node', label = d.labels[0] if (d.icon) { classes += ' node-icon' } return classes }) .on('click', (event, d) => { d.fx = d.fy = null if (typeof this.options.onNodeClick === 'function') { this.options.onNodeClick(d) } }) .on('dblclick', (event, d) => { this.stickNode(event, d) if (typeof this.options.onNodeDoubleClick === 'function') { this.options.onNodeDoubleClick(d) } }) .on('mouseenter', function (this: any, event: any, d: any) { d3.select(this).style('background', '#f0f0f0') event.target.dispatchEvent( new CustomEvent('node-mouseenter', { detail: d, bubbles: true }) ) }) .on('mouseleave', function (this: any, event: any, d: any) { d3.select(this).style('background', 'white') event.target.dispatchEvent( new CustomEvent('node-mouseleave', { detail: d, bubbles: true }) ) }) .call( d3 .drag() .on('start', this.dragStarted.bind(this)) .on('drag', this.dragged.bind(this)) .on('end', this.dragEnded.bind(this)) ) nodeEnter .append('circle') .attr('class', 'outline') .attr('r', this.options.nodeRadius) .style('fill', d => this.class2color(d.labels[0])) .style('stroke', d => this.class2darkenColor(d.labels[0])) .style('stroke-width', 2) nodeEnter .append('text') .attr('class', 'text icon') .attr('x', 0) .attr('y', 0) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') .attr('font-family', 'Material Symbols Outlined') .attr('font-size', '24px') .attr('fill', '#000') .text(d => this.getNodeIcon(d)) nodeEnter .append('text') .attr('dy', 40) .attr('text-anchor', 'middle') .text(d => d.properties.name || d.id) } stickNode(event: d3.event, d) { d.fx = event.x d.fy = event.y } private appendRelationshipsToGraph() { const relationshipEnter = this.svgRelationships .selectAll('.relationship') .data( this.relationships.filter(rel => rel.source && rel.target), d => d.id ) .enter() .append('g') .attr('class', 'relationship-group') relationshipEnter .append('line') .attr('class', 'relationship') .style('stroke', this.options.relationshipColor) .style('stroke-width', 2) .attr('marker-end', 'url(#arrow)') relationshipEnter .append('text') .attr('class', 'relationship-text') .attr('fill', '#000000') .attr('font-size', '8px') .attr('pointer-events', 'none') .attr('text-anchor', 'middle') .text(d => d.type) } private getNodeIcon(d: Node): string { return d.icon || '' } private class2color(cls: string) { if (!this.options.classes2colors[cls]) { this.options.classes2colors[cls] = this.options.colors[this.options.numClasses % this.options.colors.length] this.options.numClasses++ } return this.options.classes2colors[cls] } private class2darkenColor(cls: string) { return d3.rgb(this.class2color(cls)).darker(1) } private dragStarted(event: any, d: Node) { if (!event.active) this.simulation.alphaTarget(0.3).restart() d.fx = d.x d.fy = d.y } private dragged(event: any, d: Node) { d.fx = event.x d.fy = event.y } private dragEnded(event: any, d: Node) { if (!event.active) this.simulation.alphaTarget(0) d.fx = null d.fy = null } zoomFit() { if (!this.svg || !this.svgContainer) return const bounds = this.svg.node()?.getBBox() const svgNode = this.svgContainer.node() if (!bounds || !svgNode) return const rect = svgNode.getBoundingClientRect() const fullWidth = rect.width const fullHeight = rect.height if (bounds.width === 0 || bounds.height === 0) return const padding = 50 const scale = Math.min((fullWidth - padding) / bounds.width, (fullHeight - padding) / bounds.height) * 0.85 const centerX = bounds.x + bounds.width / 2 const centerY = bounds.y + bounds.height / 2 const transform = d3.zoomIdentity .translate(fullWidth / 2, fullHeight / 2) .scale(scale) .translate(-centerX, -centerY) // D3의 줌 상태를 직접 업데이트하여 이후 휠스크롤이 올바르게 작동하도록 함 this.svgContainer.transition().duration(750).call(this.zoom.transform, transform) } size() { return { nodes: this.nodes.length, relationships: this.relationships.length } } }