🕸️ 네트워크 시각화 — vis-network · D3 · Cytoscape

관계망(노드-엣지) 분석을 위한 8가지 패턴 · 코드 그대로 복사 → 데이터만 교체

⓪ 시작하기 · 데이터 구성
① vis-network 기본
② 양분 (시도×유형) ⭐
③ 계층 (Tree)
④ D3 Force-Directed
⑤ Cytoscape (Dagre)
⑥ 인터랙티브 조작
🗣️ 의미연결망 ⭐
⓪ 네트워크 시각화 4단계 워크플로우 + 데이터 구성
네트워크 시각화는 항상 같은 4단계입니다. 라이브러리만 다르지 흐름은 동일합니다.
  1. ① CDN 스크립트 로드 — 라이브러리(vis-network/D3/Cytoscape) 한 번만
  2. ② 컨테이너 만들기 — <div id="..."> (높이 필수!)
  3. ③ 데이터 준비 — 노드 배열 + 엣지 배열 두 가지만
  4. ④ 네트워크 생성 — new vis.Network(div, {nodes, edges}, options)
📚 모든 네트워크 데이터의 본질 — 단 두 개의 표
🎯 핵심 · 어떤 네트워크든 결국 노드 표(Nodes) + 엣지 표(Edges) 두 가지로 환원됩니다.
✨ 비유 · "사람 명단(노드) + 통화 기록(엣지)" → 통화 관계망 자동 생성.

1️⃣ 노드(Node) 표 — "점"의 명단

id (필수·고유) label (화면 표시) group (색·범주) value (크기, 선택)
서울서울특별시시도34215
차대차차대차 사고유형142350

2️⃣ 엣지(Edge) 표 — "연결"의 명단

from (시작 id) to (끝 id) value (선 굵기)
서울차대차25800
경기차대차44100
💡 핵심 규칙: 엣지의 from·to는 노드의 id와 정확히 같아야 함.

3️⃣ 외부 CSV 파일에서 데이터 불러오기 (fetch + PapaParse)

// CDN 추가:
// <script src="https://cdn.jsdelivr.net/npm/papaparse@5"></script>

Promise.all([
    fetch('nodes.csv').then(r => r.text()),
    fetch('edges.csv').then(r => r.text())
]).then(([nodeCsv, edgeCsv]) => {
    const nodes = Papa.parse(nodeCsv, { header:true, dynamicTyping:true }).data;
    const edges = Papa.parse(edgeCsv, { header:true, dynamicTyping:true }).data;
    new vis.Network(div,
        { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) },
        options);
});

4️⃣ 데이터 형태 3가지 — 어떤 분석이 가능한가

데이터 형태 예시 시각화 패턴
① 동질 네트워크
모든 노드가 같은 종류
사람-사람 친구, 키워드-키워드 동시출현 Force-Directed (④), 의미연결망 (🗣️)
② 양분 네트워크
두 종류의 노드
시도 ↔ 사고유형, 학생 ↔ 수강과목 양분 (②), Sankey
③ 계층 네트워크
부모-자식, 위→아래
전국→권역→시도, 조직도 계층 Tree (③), Cytoscape Dagre (⑤)
⚠️ 흔한 실수 4가지
  • id 중복 — 노드 표에 같은 id 두 번 → 에러
  • 이름 불일치 — 엣지의 "서울" vs 노드의 "서울특별시" → 연결 안 됨
  • 빈 값 — value 컬럼 공백 → NaN
  • 인코딩 — CSV는 UTF-8
① vis-network 기본 — 노드 + 엣지 + 물리엔진 vis-network
📚 네트워크 시각화란?
🎯 목적 · "무엇이 무엇과 연결돼 있는가?" — 관계 구조를 점(노드)과 선(엣지)으로.
✨ 특징 · vis-network는 물리엔진을 내장 — 노드끼리 밀어내고 엣지가 잡아당겨 자동 균형. 드래그로 이동 가능.
🚦 사례
  • "5건의 사망사고가 같은 운전자/차량/교차로?" 공통 인자 추적
  • 차량부품-제조사-결함 코드 네트워크
  • 음주운전 가해자-피해자 관계망
⚠️ 주의 · 노드 50개 넘으면 "털뭉치" 현상 → 필터링·계층 분리 필수.
📝 최소 패턴: 노드 배열 + 엣지 배열만 있으면 동작. 나머지는 옵션.
// ① CDN: vis-network
// <script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
// ② <div id="net1" style="height:480px"></div>   ← 높이 필수!

// ③ 데이터 준비 ─── 노드 배열 + 엣지 배열
const nodes = new vis.DataSet([
    { id:1, label:'A', color:'#3b82f6' },
    { id:2, label:'B', color:'#10b981' },
    { id:3, label:'C', color:'#ef4444' },
    { id:4, label:'D', color:'#f59e0b' },
    { id:5, label:'E', color:'#8b5cf6' }
]);
const edges = new vis.DataSet([
    { from:1, to:2 }, { from:1, to:3 }, { from:2, to:4 },
    { from:3, to:4 }, { from:4, to:5 }, { from:5, to:1 }
]);

// ④ 네트워크 생성
const network = new vis.Network(
    document.getElementById('net1'),
    { nodes, edges },
    {
        nodes: { shape: 'dot', size: 24,
                 font: { size: 14, color: 'white',
                         strokeWidth: 2, strokeColor: 'rgba(0,0,0,0.4)' } },
        edges: { width: 2, color: '#94a3b8' },
        physics: { stabilization: { iterations: 120 } }
    }
);
💡 physics 활성화 → 노드들이 자동으로 자리잡음. 드래그로 직접 옮길 수도 있음.
② ⭐ 양분 네트워크 — 시도 ↔ 사고유형 vis-network
📚 양분(Bipartite) 네트워크란?
🎯 목적 · 두 종류의 그룹(시도 vs 사고유형) 사이에 "어디가 어떤 유형이 많은지"를 한눈에.
✨ 특징 · 노드 크기 = 합계, 엣지 굵기 = 두 그룹 사이 빈도. 그룹별 색 구분 필수.
🚦 사례
  • "경기-차대차" 엣지가 가장 굵음 → 경기도가 차대차 가장 많음
  • 차종 ↔ 사고유형, 가해 연령대 ↔ 위반항목
⚠️ 주의 · 양쪽 그룹 색 구분 필수. 엣지가 너무 많으면 곡선으로 겹침 완화.
📝 핵심 옵션: group 속성으로 노드를 그룹별로 분리, groups 옵션에서 그룹별 색 정의, scaling으로 value→크기/굵기 자동 매핑.
// ② <div id="net2" style="height:480px"></div>

// ③ 데이터 준비 ─── 두 그룹의 노드 + 그 사이 엣지
const nodes = new vis.DataSet([
    { id:'서울',   label:'서울',   group:'시도', value:34215 },
    { id:'경기',   label:'경기',   group:'시도', value:58432 },
    { id:'부산',   label:'부산',   group:'시도', value:15621 },
    { id:'차대차',   label:'차대차',   group:'유형', value:142350 },
    { id:'차대사람', label:'차대사람', group:'유형', value:42580 },
    { id:'차량단독', label:'차량단독', group:'유형', value:6380 }
]);
const edges = new vis.DataSet([
    { from:'서울', to:'차대차',   value:25800 },
    { from:'서울', to:'차대사람', value:6800 },
    { from:'경기', to:'차대차',   value:44100 },
    { from:'경기', to:'차대사람', value:10800 },
    { from:'부산', to:'차대차',   value:11800 },
    { from:'부산', to:'차대사람', value:2900 }
]);

// ④ 네트워크 생성
new vis.Network(document.getElementById('net2'), { nodes, edges }, {
    nodes: { shape: 'dot',
             scaling: { min: 22, max: 60, label: { min: 12, max: 18 } } },
    groups: {                                      // 그룹별 색 정의
        시도: { color: { background: '#3b82f6' }, font: { color: 'white' } },
        유형: { color: { background: '#f59e0b' }, font: { color: 'white' } }
    },
    edges: {
        scaling: { min: 1, max: 10 },              // value → 굵기
        smooth: { type: 'curvedCW', roundness: 0.2 }
    },
    physics: { barnesHut: { gravitationalConstant: -8000, springLength: 160 } }
});
시도
사고유형
노드 크기 = 합계 · 엣지 굵기 = 건수
노드나 엣지를 클릭하면 정보가 표시됩니다
③ 계층(Tree) 레이아웃 — 위계가 분명한 구조 vis-network
📚 계층 레이아웃이란?
🎯 목적 · 부모-자식이 분명한 데이터(조직도·분류체계)를 한 방향으로 정렬.
✨ 특징 · 물리엔진 끄고 알고리즘으로 깔끔히 배치. 방향(상하·좌우) 자유 선택.
🚦 사례
  • "전국 → 4개 권역 → 17개 시도" 행정 위계
  • 사고 분류 체계, 보험 청구 흐름
⚠️ 주의 · 사이클(순환) 그래프는 표현 불가 → force-directed 사용.
📝 핵심: 일반 vis-network와 동일하지만 layout.hierarchical 옵션 추가. physics: false 필수.
// ② <div id="net3" style="height:480px"></div>

// ③ 데이터 준비 ─── 엣지 방향이 부모→자식이어야 함
const nodes = new vis.DataSet([
    { id:'전국',   label:'전국',   color:'#1e40af', font:{color:'white'} },
    { id:'수도권', label:'수도권', color:'#3b82f6', font:{color:'white'} },
    { id:'영남',   label:'영남',   color:'#3b82f6', font:{color:'white'} },
    { id:'서울',   label:'서울 34,215' },
    { id:'경기',   label:'경기 58,432' },
    { id:'부산',   label:'부산 15,621' }
]);
const edges = new vis.DataSet([
    { from:'전국',   to:'수도권' },
    { from:'전국',   to:'영남' },
    { from:'수도권', to:'서울' },
    { from:'수도권', to:'경기' },
    { from:'영남',   to:'부산' }
]);

// ④ 네트워크 생성 ─── 핵심: layout.hierarchical + physics:false
new vis.Network(document.getElementById('net3'), { nodes, edges }, {
    layout: {
        hierarchical: {
            direction: 'UD',                  // UD/DU/LR/RL 4방향
            sortMethod: 'directed',
            levelSeparation: 120,
            nodeSpacing: 130
        }
    },
    nodes: { shape: 'box', font: { size: 12 }, margin: 8 },
    edges: { arrows: 'to', smooth: { type: 'cubicBezier' } },
    physics: false                              // 계층은 끄는 게 깔끔
});
④ D3 Force-Directed — 직접 시뮬레이션 구성 D3
📚 D3 Force-Directed란?
🎯 목적 · 노드끼리 밀어내고(charge), 엣지로 잡아당기는(link) 물리 시뮬레이션을 SVG로 직접 구성. 자유도 최고.
✨ 특징 · vis-network보다 코드는 길지만 디자인 100% 제어. 학술 논문급 시각화도 가능.
🚦 사례
  • 3그룹(시도·유형·시간대) 다중 카테고리 관계망
  • 저자-인용 네트워크 같은 학술 활용
⚠️ 주의 · 학습 곡선 가파름. 빠르게 결과 필요하면 vis-network이 낫고, 디자인이 핵심이면 D3.
📝 핵심 패턴: 시뮬레이션 → SVG 그리기 → tick마다 좌표 업데이트. 데이터는 {id, group}·{source, target} 형태.
// ① CDN: D3 v7
// <script src="https://d3js.org/d3.v7.min.js"></script>
// ② <div id="net4" style="height:480px"></div>

// ③ 데이터 준비
const nodes = [
    { id:'서울', group:'시도', size:14 },
    { id:'경기', group:'시도', size:14 },
    { id:'차대차', group:'유형', size:12 },
    { id:'주중주간', group:'시간대', size:11 }
];
const links = [
    { source:'서울', target:'차대차', value:6 },
    { source:'경기', target:'차대차', value:8 },
    { source:'서울', target:'주중주간', value:5 }
];
const color = { 시도:'#3b82f6', 유형:'#f59e0b', 시간대:'#10b981' };

// ④ 시뮬레이션 + SVG 그리기
const W = 800, H = 480;
const svg = d3.select('#net4').append('svg')
    .attr('width',  '100%').attr('height', H)
    .attr('viewBox', `0 0 ${W} ${H}`);

const sim = d3.forceSimulation(nodes)
    .force('link',   d3.forceLink(links).id(d => d.id).distance(80))
    .force('charge', d3.forceManyBody().strength(-260))
    .force('center', d3.forceCenter(W/2, H/2));

const link = svg.append('g').selectAll('line').data(links).enter()
    .append('line').attr('stroke', '#94a3b8').attr('stroke-width', d => Math.sqrt(d.value));

const node = svg.append('g').selectAll('g').data(nodes).enter().append('g');
node.append('circle').attr('r', d => d.size).attr('fill', d => color[d.group]);
node.append('text').text(d => d.id).attr('x', d => d.size+4).attr('y', 4);

// 매 프레임마다 좌표 업데이트
sim.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})`);
});
⑤ Cytoscape.js — 그래프 분석 라이브러리 Cytoscape + Dagre
📚 Cytoscape란?
🎯 목적 · 시각화 + 그래프 알고리즘(최단경로·중심성·커뮤니티 검출)을 한 라이브러리에서.
✨ 특징 · 다양한 레이아웃(dagre·cose·circle·grid·concentric·breadthfirst) 즉시 전환.
🚦 사례
  • 도로 네트워크 → 가장 가까운 병원까지 최단경로 계산
  • 교차로 노드 중심성 분석
⚠️ 주의 · 시각화만이면 vis-network가 더 가벼움. Cytoscape는 분석 기능을 위한 선택.
📝 데이터 형식: elements 배열에 노드와 엣지를 함께 넣음. 각각 { data: { id } }·{ data: { source, target } } 형태.
// ① CDN: Cytoscape + dagre + cytoscape-dagre 어댑터
// <script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
// <script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
// <script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
// ② <div id="net5" style="height:480px"></div>

cytoscape.use(cytoscapeDagre);                    // 어댑터 등록

// ③ 데이터 준비 ─── 노드와 엣지를 한 elements 배열에
const elements = [
    { data: { id:'전국' } },
    { data: { id:'수도권' } }, { data: { id:'영남' } },
    { data: { id:'서울' } },   { data: { id:'경기' } },
    { data: { id:'부산' } },   { data: { id:'대구' } },
    { data: { source:'전국',   target:'수도권' } },
    { data: { source:'전국',   target:'영남' } },
    { data: { source:'수도권', target:'서울' } },
    { data: { source:'수도권', target:'경기' } },
    { data: { source:'영남',   target:'부산' } },
    { data: { source:'영남',   target:'대구' } }
];

// ④ Cytoscape 인스턴스 생성
const cy = cytoscape({
    container: document.getElementById('net5'),
    elements,
    style: [
        { selector: 'node', style: {
            'background-color': '#4338ca', 'label': 'data(id)',
            'color': 'white', 'text-valign': 'center', 'text-halign': 'center',
            'font-size': 11, 'width': 52, 'height': 52
        }},
        { selector: 'edge', style: {
            'width': 2, 'line-color': '#94a3b8',
            'curve-style': 'bezier',
            'target-arrow-shape': 'triangle'
        }}
    ],
    layout: { name: 'dagre', rankDir: 'TB' }   // 다른 옵션: cose/circle/grid/...
});

// 레이아웃 즉시 변경
// cy.layout({ name: 'cose', animate: true }).run();
⑥ ⭐ 인터랙티브 조작 — 노드/엣지 추가·삭제·이웃 강조 vis-network
📚 인터랙티브 그래프 편집이란?
🎯 목적 · 정적 그림이 아니라 독자가 그래프를 직접 수정·탐색. 데이터 입력 도구로도 활용.
✨ 특징 · vis-network DataSet은 add()/remove()/update()만 호출하면 화면 자동 갱신.
🚦 사례
  • 수사관이 새 노드 추가하며 관계망 구성
  • 특정 노드 클릭 → 그가 관여한 사고/차량/피해자만 강조
⚠️ 주의 · 변경된 데이터는 화면에만 남음. 영구 저장하려면 서버 API 또는 localStorage 필요.
📝 핵심 API 4가지: add()·remove()·update()·getConnectedNodes().
// ② <div id="net6" style="height:480px"></div>

// ③ DataSet은 변경 가능한 컬렉션 ─── add/remove/update가 화면 즉시 갱신
const nodes = new vis.DataSet([
    { id:1, label:'A', color:'#3b82f6' },
    { id:2, label:'B', color:'#10b981' },
    { id:3, label:'C', color:'#ef4444' }
]);
const edges = new vis.DataSet([
    { id:'e1', from:1, to:2 },
    { id:'e2', from:2, to:3 }
]);

// ④ 네트워크 생성
const net = new vis.Network(document.getElementById('net6'), { nodes, edges }, {
    nodes: { shape: 'dot', size: 24 },
    interaction: { multiselect: true, selectConnectedEdges: true }
});

// ─── 핵심 API 4가지 ───
// 1) 노드 추가 ─── add 호출 즉시 화면에 반영
nodes.add({ id: 99, label: 'NEW', color: '#f59e0b' });

// 2) 엣지 추가
edges.add({ id: 'e99', from: 1, to: 99 });

// 3) 선택된 항목 삭제
const sel = net.getSelection();          // { nodes:[...], edges:[...] }
sel.nodes.forEach(id => nodes.remove(id));
sel.edges.forEach(id => edges.remove(id));

// 4) 클릭 → 이웃만 강조
net.on('click', params => {
    if (params.nodes.length === 0) return;
    const id = params.nodes[0];
    const neighbors = net.getConnectedNodes(id);
    nodes.update(nodes.get().map(n => ({
        id: n.id,
        opacity: (n.id === id || neighbors.includes(n.id)) ? 1 : 0.25
    })));
});

// 5) 화면 맞춤
net.fit({ animation: true });
노드를 클릭하면 직접 연결된 이웃이 강조됩니다
🗣️ ⭐ 의미연결망 — 텍스트 속 단어 관계 vis-network
📚 의미연결망이란?
🎯 목적 · 워드클라우드가 "어떤 단어가 자주 나오는가"라면, 의미연결망은 "어떤 단어들이 함께 나오는가"까지. 키워드 사이의 관계 구조가 드러남.
✨ 핵심 원리 — 동시출현(co-occurrence)
  • 같은 문장 안에 두 단어가 함께 등장 → 두 단어 사이 엣지 +1
  • 여러 문장에서 자주 같이 나올수록 엣지 두꺼워짐
  • 중심성 높은 단어 = 담론의 핵심 키워드
🚦 사례
  • 경찰 조서 → "음주" "야간" "신호위반"이 강하게 연결되면 야간 음주 패턴 형성
  • 국민 민원·SNS → "어린이" "보호구역" "과속" 군집 발견
  • 뉴스 기사 키워드 프레임 분석
⚠️ 주의 · 동시출현 ≠ 인과. 불용어와 너무 흔한 단어는 모든 단어와 연결되어 의미가 흐려짐.
📝 4단계 자동 생성: ① 문장 분리 ② 토큰화+불용어 제거 ③ 빈도+동시출현 카운트 ④ 노드/엣지로 변환 → vis-network에 입력.
// ② <textarea id="sem-text">...</textarea> + <div id="net7" style="height:480px"></div>

// ③ 데이터 준비 ─── 텍스트만 자기 데이터로 교체!
const text = document.getElementById('sem-text').value;

// 1) 문장 단위로 쪼개기
const sentences = text.split(/[.!?\n]+/).map(s => s.trim()).filter(Boolean);

// 2) 토큰화 + 불용어 제거
const stop = new Set(['은','는','이','가','을','를','의','에','와','과']);
function tokens(s) {
    return s.split(/[\s,·\-—()【】\[\]"'?!:;]+/)
            .filter(w => w.length >= 2 && !stop.has(w));
}

// 3) 단어 빈도 + 단어 쌍 동시출현 카운트
const freq = {}, pair = {};
sentences.forEach(s => {
    const ws = [...new Set(tokens(s))];        // 같은 문장 내 중복 제거
    ws.forEach(w => { freq[w] = (freq[w]||0) + 1; });
    for (let i=0; i<ws.length; i++)
        for (let j=i+1; j<ws.length; j++) {
            const k = [ws[i], ws[j]].sort().join('|');
            pair[k] = (pair[k]||0) + 1;
        }
});

// 4) 상위 N개 단어 → 노드, 동시출현 ≥ 2 → 엣지
const topN = 25, minPair = 2;
const top = Object.entries(freq).sort((a,b)=>b[1]-a[1]).slice(0, topN);
const wordSet = new Set(top.map(w => w[0]));

const nodes = top.map(([w,c]) => ({ id: w, label: w, value: c }));
const edges = Object.entries(pair)
    .filter(([k,v]) => v >= minPair && k.split('|').every(w => wordSet.has(w)))
    .map(([k,v]) => { const [a,b] = k.split('|'); return { from:a, to:b, value:v }; });

// ④ 네트워크 생성 ─── force-directed 자동 배치
new vis.Network(document.getElementById('net7'),
    { nodes: new vis.DataSet(nodes), edges: new vis.DataSet(edges) },
    {
        nodes: { shape: 'dot', scaling: { min: 12, max: 50 } },
        edges: { scaling: { min: 1, max: 8 }, color: { color: '#cbd5e1' } },
        physics: { barnesHut: { gravitationalConstant: -3500, springLength: 140 } }
    }
);
상단 텍스트를 수정하고 ▶ 분석 버튼을 누르세요.