habix.ai · Brain
페이지 (wiki)
concept
tool
project
insight
lecture
business
메타
tag
ghost
드래그·휠로 이동·확대 · 노드 클릭 시 상세
또는 임의 markup 이 섞여 있어도 build-brain.mjs 가 svg.clientWidth || 800; const H = () => svg.clientHeight || 600; // kind 별 시각 속성: page=채워진 원(type색), tag=외곽선만, ghost=점선 const nodes = DATA.nodes.map(n => { const kind = n.kind || 'page'; let r, color, isHub; if (kind === 'page') { r = 3 + Math.sqrt(n.inbound + 1) * 2.2; color = TYPE_COLOR[n.type] || TYPE_COLOR.unknown; isHub = n.inbound >= 5; } else if (kind === 'tag') { r = 2.5 + Math.sqrt(n.inbound + 1) * 1.0; // 페이지보다 작음 color = '#94a3b8'; isHub = n.inbound >= 8; } else { // ghost r = 4; color = '#64748b'; isHub = false; } return Object.assign({}, n, { x: W()/2 + (Math.random()-0.5) * 600, y: H()/2 + (Math.random()-0.5) * 600, vx: 0, vy: 0, kind, r, color, isHub }); }); const idIndex = Object.fromEntries(nodes.map(n => [n.id, n])); const links = DATA.links .map(l => ({ source: idIndex[l.source], target: idIndex[l.target] })) .filter(l => l.source && l.target); // ---- 뷰포트 (zoom·pan) ---- let view = { tx: 0, ty: 0, k: 1 }; const root = document.createElementNS(SVG_NS, 'g'); svg.appendChild(root); const linkLayer = document.createElementNS(SVG_NS, 'g'); const nodeLayer = document.createElementNS(SVG_NS, 'g'); const labelLayer = document.createElementNS(SVG_NS, 'g'); root.appendChild(linkLayer); root.appendChild(nodeLayer); root.appendChild(labelLayer); // SVG 엘리먼트 생성 const linkEls = links.map(l => { const el = document.createElementNS(SVG_NS, 'line'); el.setAttribute('stroke', '#475569'); el.setAttribute('stroke-opacity', '0.25'); el.setAttribute('stroke-width', '1'); linkLayer.appendChild(el); return el; }); const nodeEls = nodes.map(n => { const el = document.createElementNS(SVG_NS, 'circle'); el.setAttribute('r', n.r); if (n.kind === 'page') { el.setAttribute('fill', n.color); el.setAttribute('stroke', '#0a0e1f'); el.setAttribute('stroke-width', '1.2'); } else if (n.kind === 'tag') { el.setAttribute('fill', 'transparent'); el.setAttribute('stroke', n.color); el.setAttribute('stroke-width', '1.2'); } else { // ghost el.setAttribute('fill', 'transparent'); el.setAttribute('stroke', n.color); el.setAttribute('stroke-width', '1'); el.setAttribute('stroke-dasharray', '2,2'); } el.style.cursor = 'pointer'; el.dataset.id = n.id; el.dataset.kind = n.kind; nodeLayer.appendChild(el); return el; }); const labelEls = nodes.map(n => { const el = document.createElementNS(SVG_NS, 'text'); el.setAttribute('text-anchor', 'middle'); el.setAttribute('class', n.isHub ? 'node-label hub' : 'node-label'); el.textContent = n.title; el.style.display = n.isHub ? 'block' : 'none'; labelLayer.appendChild(el); return el; }); function applyTransform(){ root.setAttribute('transform', `translate(${view.tx},${view.ty}) scale(${view.k})`); } applyTransform(); // ---- Force simulation ---- // F_rep: ∝ 1/d² (Coulomb), F_spring: 인접한 노드 끌어당김, center: 중앙으로 약한 인력 // 261 노드용 파라미터 — 노드 수 증가로 REP·SPRING_L 축소. const REP = 700; // 반발 강도 (노드 많을수록 ↓) const SPRING_K = 0.02; // 스프링 강도 const SPRING_L = 45; // 스프링 자연 길이 const CENTER_K = 0.006; // 중앙 인력 const DAMP = 0.86; // 속도 감쇠 const MAX_VEL = 12; let alpha = 1; let dragNode = null; function tick(){ // 정지 상태: 시뮬 skip → 노드 좌표 stable → Playwright/사용자 클릭 정확도 ↑ if (alpha < 0.02 && !dragNode) { alpha = 0; requestAnimationFrame(tick); return; } alpha *= 0.995; const cx = W()/2, cy = H()/2; // 1) 반발 for (let i=0; i MAX_VEL) n.vx = MAX_VEL; if (n.vx < -MAX_VEL) n.vx = -MAX_VEL; if (n.vy > MAX_VEL) n.vy = MAX_VEL; if (n.vy < -MAX_VEL) n.vy = -MAX_VEL; n.x += n.vx; n.y += n.vy; } render(); requestAnimationFrame(tick); } function render(){ for (let i=0; i { if (e.target.tagName === 'circle'){ const id = e.target.dataset.id; const n = idIndex[id]; dragNode = n; svg.classList.add('dragging'); alpha = Math.max(alpha, 0.3); // 흔들어 깨우기 } else { panStart = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty }; svg.classList.add('dragging'); } }); window.addEventListener('mousemove', (e) => { if (dragNode){ const pt = screenToWorld(e.clientX, e.clientY); dragNode.x = pt.x; dragNode.y = pt.y; } else if (panStart){ view.tx = panStart.tx + (e.clientX - panStart.x); view.ty = panStart.ty + (e.clientY - panStart.y); applyTransform(); } }); window.addEventListener('mouseup', (e) => { if (dragNode){ // 클릭 처리 (드래그 거리 짧으면) const dist = Math.hypot(dragNode.vx, dragNode.vy); if (dist < 0.1 && e.target.tagName === 'circle'){ showPanel(dragNode); } } dragNode = null; panStart = null; svg.classList.remove('dragging'); }); svg.addEventListener('wheel', (e) => { e.preventDefault(); const rect = svg.getBoundingClientRect(); const mx = e.clientX - rect.left, my = e.clientY - rect.top; const delta = e.deltaY < 0 ? 1.12 : 1/1.12; const newK = Math.min(4, Math.max(0.25, view.k * delta)); // zoom centered on cursor view.tx = mx - (mx - view.tx) * (newK / view.k); view.ty = my - (my - view.ty) * (newK / view.k); view.k = newK; applyTransform(); }, { passive: false }); function screenToWorld(sx, sy){ const rect = svg.getBoundingClientRect(); return { x: (sx - rect.left - view.tx) / view.k, y: (sy - rect.top - view.ty) / view.k }; } // hover let hoverNode = null; nodeEls.forEach((el, i) => { el.addEventListener('mouseenter', () => { hoverNode = nodes[i]; highlightNeighbors(hoverNode); }); el.addEventListener('mouseleave', () => { hoverNode = null; resetHighlight(); }); el.addEventListener('click', (e) => { e.stopPropagation(); showPanel(nodes[i]); }); }); function highlightNeighbors(center){ const neighborIds = new Set([center.id]); for (const l of links){ if (l.source.id === center.id) neighborIds.add(l.target.id); if (l.target.id === center.id) neighborIds.add(l.source.id); } nodeEls.forEach((el, i) => { el.setAttribute('opacity', neighborIds.has(nodes[i].id) ? '1' : '0.18'); }); linkEls.forEach((el, i) => { const l = links[i]; const on = l.source.id === center.id || l.target.id === center.id; el.setAttribute('stroke', on ? '#a78bfa' : '#475569'); el.setAttribute('stroke-opacity', on ? '0.85' : '0.08'); el.setAttribute('stroke-width', on ? '1.6' : '1'); }); labelEls.forEach((el, i) => { el.style.display = neighborIds.has(nodes[i].id) ? 'block' : (nodes[i].isHub ? 'block' : 'none'); if (neighborIds.has(nodes[i].id)) el.setAttribute('opacity', '1'); else el.setAttribute('opacity', nodes[i].isHub ? '0.3' : '0'); }); } function resetHighlight(){ nodeEls.forEach(el => el.setAttribute('opacity', '1')); linkEls.forEach(el => { el.setAttribute('stroke', '#475569'); el.setAttribute('stroke-opacity', '0.25'); el.setAttribute('stroke-width', '1'); }); labelEls.forEach((el, i) => { el.style.display = nodes[i].isHub ? 'block' : 'none'; el.setAttribute('opacity', '1'); }); } // ---- 사이드 패널 ---- const panel = document.getElementById('panel'); const panelInner = panel.querySelector('.panel-inner'); document.getElementById('closeBtn').addEventListener('click', () => { panel.classList.remove('active'); }); function renderListHtml(arr){ if (arr.length === 0) return '
  • 없음
  • '; return arr.map(t => `
  • ${escapeHtml(t.title)}·${t.inbound}
  • `).join(''); } function showPanel(n){ const html = n.kind === 'tag' ? renderTagPanel(n) : n.kind === 'ghost' ? renderGhostPanel(n) : renderPagePanel(n); panelInner.innerHTML = html; panelInner.querySelector('#closeBtn').addEventListener('click', () => panel.classList.remove('active')); panelInner.querySelectorAll('ul li[data-id]').forEach(li => { li.addEventListener('click', () => { const tgt = idIndex[li.dataset.id]; if (tgt) { showPanel(tgt); focusNode(tgt); } }); }); panelInner.querySelectorAll('.tag[data-tagid]').forEach(b => { b.style.cursor = 'pointer'; b.addEventListener('click', () => { const tgt = idIndex[b.dataset.tagid]; if (tgt) { showPanel(tgt); focusNode(tgt); } }); }); panel.classList.add('active'); } function renderPagePanel(n){ // outbound 분리: page / tag / ghost const outPages = links.filter(l => l.source.id === n.id && l.target.kind === 'page').map(l => l.target); const outTags = links.filter(l => l.source.id === n.id && l.target.kind === 'tag').map(l => l.target); const outGhosts = links.filter(l => l.source.id === n.id && l.target.kind === 'ghost').map(l => l.target); const inPages = links.filter(l => l.target.id === n.id).map(l => l.source); const domainsHtml = (n.domain || []).map(d => `${escapeHtml(d)}`).join(''); const tagsBadgesHtml = (n.tags || []).map(t => `#${escapeHtml(t)}`).join(''); return `

    ${escapeHtml(n.title)}

    ${escapeHtml(n.type)} ${domainsHtml}
    인바운드 ${n.inbound} 참조: 페이지 ${outPages.length} · 태그 ${outTags.length}${outGhosts.length ? ` · 미정제 ${outGhosts.length}` : ''}
    ${tagsBadgesHtml ? `
    ${tagsBadgesHtml}
    ` : ''} ${outGhosts.length ? `` : ''}
    slug: ${escapeHtml(n.id)}
    `; } function renderTagPanel(n){ const sources = links.filter(l => l.target.id === n.id && l.source.kind === 'page').map(l => l.source); return `

    ${escapeHtml(n.title)}

    tag
    이 태그를 가진 페이지 ${sources.length}
    `; } function renderGhostPanel(n){ const sources = links.filter(l => l.target.id === n.id).map(l => l.source); return `

    ${escapeHtml(n.title)}

    ghost

    wiki에 아직 페이지가 없는 슬러그. 이 슬러그가 자주 참조되면 정제해서 페이지로 추가할 후보입니다.

    참조하는 페이지 ${sources.length}
    slug: ${escapeHtml(n.title)}
    `; } function focusNode(n){ // 카메라를 부드럽게 노드 중심으로 const cx = W()/2, cy = H()/2; const k = Math.max(view.k, 1.2); view.tx = cx - n.x * k; view.ty = cy - n.y * k; view.k = k; applyTransform(); alpha = Math.max(alpha, 0.2); } function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } // ---- 통계 표시 ---- document.getElementById('stats').innerHTML = `${DATA.meta.total_pages} nodes · ${DATA.meta.total_links} links · 평균 차수 ${(DATA.meta.total_links*2/DATA.meta.total_pages).toFixed(1)}`; requestAnimationFrame(tick); })();