Files
brain_dogfood/static/js/components/Visualizer.js
T

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();
}
};