mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-24 19:48:35 +09:00
287 lines
11 KiB
JavaScript
287 lines
11 KiB
JavaScript
/**
|
|
* 지식 시각화 맵(Graph) 관리 모듈 (v7.5 - D3.js 기반 혁신)
|
|
*/
|
|
import { I18nManager } from '../utils/I18nManager.js';
|
|
import { Constants } from '../utils/Constants.js';
|
|
|
|
export const Visualizer = {
|
|
simulation: null,
|
|
svg: null,
|
|
container: null,
|
|
width: 0,
|
|
height: 0,
|
|
|
|
init(containerId) {
|
|
this.container = document.getElementById(containerId);
|
|
if (!this.container) {
|
|
console.error(`[Visualizer] Container #${containerId} not found.`);
|
|
return;
|
|
}
|
|
|
|
// 초기 크기 설정
|
|
this.width = this.container.clientWidth;
|
|
this.height = this.container.clientHeight;
|
|
console.log(`[Visualizer] Init - Size: ${this.width}x${this.height}`);
|
|
},
|
|
|
|
render(memos, onNodeClick) {
|
|
console.log(`[Visualizer] Rendering ${memos.length} memos...`);
|
|
if (!this.container) return;
|
|
|
|
// 모달이 열리는 중이라 크기가 0일 경우 대비 재측정
|
|
if (this.width === 0 || this.height === 0) {
|
|
this.width = this.container.clientWidth || 800;
|
|
this.height = this.container.clientHeight || 600;
|
|
console.log(`[Visualizer] Re-measured Size: ${this.width}x${this.height}`);
|
|
}
|
|
|
|
// 0. 기존 내용 청소
|
|
this.container.innerHTML = '';
|
|
|
|
// 1. 데이터 전처리
|
|
const uniqueGroups = [...new Set(memos.map(m => m.group_name || Constants.GROUPS.DEFAULT))];
|
|
const groupCenters = {};
|
|
const radius = Math.min(this.width, this.height) * 0.35;
|
|
|
|
// 그룹별 성단 중심점 계산 (원형 레이아웃)
|
|
uniqueGroups.forEach((g, i) => {
|
|
const angle = (i / uniqueGroups.length) * Math.PI * 2;
|
|
groupCenters[g] = {
|
|
x: this.width / 2 + Math.cos(angle) * radius,
|
|
y: this.height / 2 + Math.sin(angle) * radius
|
|
};
|
|
});
|
|
|
|
const nodes = memos.map(m => ({
|
|
...m,
|
|
id: m.id.toString(),
|
|
group: m.group_name || Constants.GROUPS.DEFAULT,
|
|
weight: (m.links ? m.links.length : 0) + 5
|
|
}));
|
|
|
|
const links = [];
|
|
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
|
|
// 1. 명시적 링크 (Internal Links) 처리
|
|
memos.forEach(m => {
|
|
if (m.links) {
|
|
m.links.forEach(l => {
|
|
const targetId = (l.target_id || l.id).toString();
|
|
if (nodeMap.has(targetId)) {
|
|
links.push({ source: m.id.toString(), target: targetId, type: 'explicit' });
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// 2. 공통 태그 및 그룹 기반 자동 연결 (Constellation Links)
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
const nodeA = nodes[i];
|
|
const nodeB = nodes[j];
|
|
|
|
// 태그 목록 추출
|
|
const tagsA = new Set((nodeA.tags || []).map(t => t.name));
|
|
const tagsB = new Set((nodeB.tags || []).map(t => t.name));
|
|
|
|
// 교집합 확인 (태그 링크)
|
|
const commonTags = [...tagsA].filter(t => tagsB.has(t));
|
|
if (commonTags.length > 0) {
|
|
links.push({
|
|
source: nodeA.id,
|
|
target: nodeB.id,
|
|
type: 'tag',
|
|
strength: commonTags.length
|
|
});
|
|
} else if (nodeA.group === nodeB.group) {
|
|
// 동일 그룹 내 자동 연결 (성단 형성) - 태그가 없을 때만
|
|
links.push({
|
|
source: nodeA.id,
|
|
target: nodeB.id,
|
|
type: 'group',
|
|
strength: 0.1
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`[Visualizer] Data Prepared - Nodes: ${nodes.length}, Links: ${links.length}, Groups: ${uniqueGroups.length}`);
|
|
const totalTags = nodes.reduce((acc, n) => acc + (n.tags ? n.tags.length : 0), 0);
|
|
console.log(`[Visualizer] Total Tags in Data: ${totalTags}`);
|
|
|
|
// 2. SVG 생성
|
|
this.svg = d3.select(this.container)
|
|
.append('svg')
|
|
.attr('width', '100%')
|
|
.attr('height', '100%')
|
|
.style('background', 'radial-gradient(circle at center, #1e293b 0%, #020617 100%)')
|
|
.attr('viewBox', `0 0 ${this.width} ${this.height}`);
|
|
|
|
// 우주 배경 (작은 별들) 생성
|
|
const starCount = 100;
|
|
const stars = Array.from({ length: starCount }, () => ({
|
|
x: Math.random() * this.width,
|
|
y: Math.random() * this.height,
|
|
r: Math.random() * 1.5,
|
|
opacity: Math.random()
|
|
}));
|
|
|
|
this.svg.selectAll('.star')
|
|
.data(stars)
|
|
.enter()
|
|
.append('circle')
|
|
.attr('class', 'star')
|
|
.attr('cx', d => d.x)
|
|
.attr('cy', d => d.y)
|
|
.attr('r', d => d.r)
|
|
.style('fill', '#fff')
|
|
.style('opacity', d => d.opacity);
|
|
|
|
// 글로우 효과 필터 정의
|
|
const defs = this.svg.append('defs');
|
|
const filter = defs.append('filter')
|
|
.attr('id', 'glow');
|
|
filter.append('feGaussianBlur')
|
|
.attr('stdDeviation', '3.5')
|
|
.attr('result', 'coloredBlur');
|
|
const feMerge = filter.append('feMerge');
|
|
feMerge.append('feMergeNode').attr('in', 'coloredBlur');
|
|
feMerge.append('feMergeNode').attr('in', 'SourceGraphic');
|
|
|
|
const g = this.svg.append('g').attr('class', 'main-g');
|
|
|
|
// 3. 줌(Zoom) 설정
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.1, 5])
|
|
.on('zoom', (event) => g.attr('transform', event.transform));
|
|
this.svg.call(zoom);
|
|
|
|
// 4. 그룹 라벨 생성 (Subtle Center Labels)
|
|
const groupLabels = g.selectAll('.group-label')
|
|
.data(uniqueGroups)
|
|
.join('text')
|
|
.attr('class', 'group-label')
|
|
.attr('x', d => groupCenters[d].x)
|
|
.attr('y', d => groupCenters[d].y)
|
|
.text(d => d)
|
|
.style('fill', 'rgba(56, 189, 248, 0.2)')
|
|
.style('font-size', '14px')
|
|
.style('font-weight', 'bold')
|
|
.style('text-anchor', 'middle')
|
|
.style('pointer-events', 'none');
|
|
|
|
// 5. 물리 시뮬레이션 설정 (Force Simulation)
|
|
this.simulation = d3.forceSimulation(nodes)
|
|
.force('link', d3.forceLink(links).id(d => d.id).distance(100).strength(0.1))
|
|
.force('charge', d3.forceManyBody().strength(-200)) // 서로 밀어냄
|
|
.force('collide', d3.forceCollide().radius(d => d.weight + 20))
|
|
.force('x', d3.forceX(d => groupCenters[d.group].x).strength(0.08)) // 그룹 중심으로 당김
|
|
.force('y', d3.forceY(d => groupCenters[d.group].y).strength(0.08))
|
|
.force('center', d3.forceCenter(this.width / 2, this.height / 2).strength(0.01));
|
|
|
|
// 6. 링크(선) 활성화
|
|
const link = g.selectAll('.link')
|
|
.data(links)
|
|
.join('line')
|
|
.attr('class', 'link')
|
|
.style('stroke', d => {
|
|
if (d.type === 'explicit') return '#38bdf8';
|
|
if (d.type === 'tag') return '#8b5cf6';
|
|
return 'rgba(56, 189, 248, 0.05)'; // group links
|
|
})
|
|
.style('stroke-width', d => d.type === 'explicit' ? 2 : 1)
|
|
.style('stroke-dasharray', d => d.type === 'group' ? '2,2' : 'none')
|
|
.style('opacity', d => d.type === 'group' ? 0.3 : 0.6);
|
|
|
|
// 7. 노드(점) 활성화
|
|
const node = g.selectAll('.node')
|
|
.data(nodes)
|
|
.join('g')
|
|
.attr('class', d => `node ${d.is_encrypted ? 'encrypted' : ''}`)
|
|
.call(d3.drag()
|
|
.on('start', dragstarted)
|
|
.on('drag', dragged)
|
|
.on('end', dragended))
|
|
.on('click', (event, d) => onNodeClick && onNodeClick(d.id))
|
|
.on('mouseover', function(event, d) {
|
|
// 이웃 노드 및 링크 하이라이트
|
|
const neighborIds = new Set();
|
|
neighborIds.add(d.id);
|
|
links.forEach(l => {
|
|
if (l.source.id === d.id) neighborIds.add(l.target.id);
|
|
if (l.target.id === d.id) neighborIds.add(l.source.id);
|
|
});
|
|
|
|
node.style('opacity', n => neighborIds.has(n.id) ? 1 : 0.1);
|
|
link.style('stroke', l => (l.source.id === d.id || l.target.id === d.id) ? '#38bdf8' : 'rgba(56, 189, 248, 0.05)')
|
|
.style('stroke-opacity', l => (l.source.id === d.id || l.target.id === d.id) ? 1 : 0.2);
|
|
})
|
|
.on('mouseout', function() {
|
|
node.style('opacity', 1);
|
|
link.style('stroke', 'rgba(56, 189, 248, 0.1)')
|
|
.style('stroke-opacity', 0.6);
|
|
});
|
|
|
|
// 노드 원형 스타일
|
|
node.append('circle')
|
|
.attr('r', d => d.weight)
|
|
.style('fill', d => d.is_encrypted ? '#64748b' : '#38bdf8')
|
|
.style('filter', 'url(#glow)')
|
|
.style('cursor', 'pointer');
|
|
|
|
// 노드 텍스트 라벨
|
|
node.append('text')
|
|
.attr('dy', d => d.weight + 15)
|
|
.text(d => {
|
|
const untitled = I18nManager.t('label_untitled');
|
|
const title = d.title || untitled;
|
|
return d.is_encrypted ? `🔒 ${title}` : title;
|
|
})
|
|
.style('fill', d => d.is_encrypted ? '#94a3b8' : '#cbd5e1')
|
|
.style('font-size', '10px')
|
|
.style('text-anchor', 'middle')
|
|
.style('pointer-events', 'none')
|
|
.style('text-shadow', '0 2px 4px rgba(0,0,0,0.8)');
|
|
|
|
// 8. 틱(Tick)마다 좌표 업데이트
|
|
this.simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
|
|
node
|
|
.attr('transform', d => `translate(${d.x}, ${d.y})`);
|
|
});
|
|
|
|
// 드래그 함수
|
|
const self = this;
|
|
function dragstarted(event, d) {
|
|
if (!event.active) self.simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
}
|
|
|
|
function dragged(event, d) {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
}
|
|
|
|
function dragended(event, d) {
|
|
if (!event.active) self.simulation.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
}
|
|
},
|
|
|
|
resize() {
|
|
if (!this.container || !this.svg) return;
|
|
this.width = this.container.clientWidth;
|
|
this.height = this.container.clientHeight;
|
|
this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`);
|
|
this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2));
|
|
this.simulation.alpha(0.3).restart();
|
|
}
|
|
};
|