답변 5개 / 댓글 10개
채택된 답변
+20 포인트
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flowchart Diagram</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #f4f6f9;
--card: #ffffff;
--border: #e2e6ed;
--border-hover: #b8bfcc;
--text: #2d3748;
--text-sub: #718096;
--accent: #4f6ef7;
--accent-light: #eef1fe;
--line: #b0bac9;
--line-vert: #4f6ef7;
--danger: #e8574f;
--shadow-hover: 0 6px 20px rgba(0,0,0,0.08);
--radius: 10px;
}
body {
font-family: 'Noto Sans KR', sans-serif;
background: var(--bg);
min-height: 100vh;
padding: 32px;
color: var(--text);
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 28px;
flex-wrap: wrap;
align-items: center;
}
.toolbar button {
padding: 8px 18px;
border: 1.5px solid var(--border);
border-radius: 8px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
background: var(--card);
color: var(--text);
}
.toolbar button:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-light);
}
.toolbar button.danger { color: var(--danger); }
.toolbar button.danger:hover {
border-color: var(--danger);
background: #fef2f2;
}
.toolbar .sep {
width: 1px;
height: 28px;
background: var(--border);
margin: 0 4px;
}
/* ── 다이어그램 ── */
.diagram-wrapper {
position: relative;
width: 100%;
}
svg.lines-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 1;
}
svg.lines-overlay line.h-line {
stroke: var(--line);
stroke-width: 1.4;
stroke-dasharray: 5 3;
}
svg.lines-overlay path.v-path {
fill: none;
stroke: var(--line-vert);
stroke-width: 1.4;
stroke-dasharray: 4 3;
}
svg.lines-overlay marker path {
fill: var(--line);
stroke: none;
}
.diagram-grid {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: start;
position: relative;
z-index: 2;
}
.col {
display: flex;
flex-direction: column;
gap: 0;
padding: 16px;
}
/* ── 행 ── */
.node-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.col.left .node-row { justify-content: flex-end; }
.col.right .node-row { justify-content: flex-start; }
.node-boxes {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
border-radius: var(--radius);
border: 1.5px solid transparent;
transition: all 0.2s;
}
.node-boxes.is-pair {
border-color: var(--border);
background: rgba(255,255,255,0.6);
}
.node-boxes.is-pair:hover {
border-color: var(--border-hover);
}
.box {
width: 110px;
height: 58px;
background: var(--card);
border-radius: 8px;
border: 1.5px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 12.5px;
font-weight: 600;
color: var(--text);
transition: all 0.2s;
flex-shrink: 0;
}
.box:hover {
border-color: var(--accent);
box-shadow: var(--shadow-hover);
}
.mode-btn {
width: 26px;
height: 26px;
border-radius: 6px;
border: 1.5px solid var(--border);
background: var(--card);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
z-index: 5;
}
.mode-btn:hover {
border-color: var(--accent);
background: var(--accent-light);
}
.mode-btn .dot { fill: var(--text-sub); }
.mode-btn.is-pair .dot { fill: var(--accent); }
/* ── 행간 연결 버튼 ── */
.vert-connector {
display: flex;
padding: 2px 0;
}
.col.left .vert-connector { justify-content: flex-end; padding-right: 42px; }
.col.right .vert-connector { justify-content: flex-start; padding-left: 42px; }
.vert-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1.5px solid var(--border);
background: var(--card);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
z-index: 5;
}
.vert-btn:hover {
border-color: var(--line-vert);
background: var(--accent-light);
}
.vert-btn.active {
background: var(--line-vert);
border-color: var(--line-vert);
}
.vert-btn .icon-line {
stroke: var(--text-sub);
stroke-width: 2;
}
.vert-btn.active .icon-line { stroke: white; }
/* ── 중앙 ── */
.center-col {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 48px;
align-self: center;
}
.center-box {
width: 140px;
height: 100px;
background: var(--card);
border-radius: 12px;
border: 2px solid var(--border);
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: var(--text);
z-index: 3;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.node-row { animation: slideIn 0.25s ease; }
</style>
</head>
<body>
<div class="toolbar">
<button onclick="addRow('left')">+ 왼쪽 행</button>
<button onclick="addRow('right')">+ 오른쪽 행</button>
<div class="sep"></div>
<button class="danger" onclick="removeLastRow('left')">− 왼쪽 삭제</button>
<button class="danger" onclick="removeLastRow('right')">− 오른쪽 삭제</button>
</div>
<div class="diagram-wrapper" id="diagramWrapper">
<svg class="lines-overlay" id="linesOverlay">
<defs>
<marker id="arrowR" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L7,2.5 L0,5 Z" />
</marker>
</defs>
</svg>
<div class="diagram-grid">
<div class="col left" id="colLeft"></div>
<div class="center-col">
<div class="center-box" id="centerBox">Center</div>
</div>
<div class="col right" id="colRight"></div>
</div>
</div>
<script>
const svgNS = 'http://www.w3.org/2000/svg';
let leftIdx = 0, rightIdx = 0;
function addRow(side, redraw = true) {
const col = document.getElementById(side === 'left' ? 'colLeft' : 'colRight');
const idx = side === 'left' ? ++leftIdx : ++rightIdx;
const pre = side === 'left' ? 'L' : 'R';
const existingRows = col.querySelectorAll('.node-row');
if (existingRows.length > 0) {
const vc = document.createElement('div');
vc.className = 'vert-connector';
const btn = document.createElement('button');
btn.className = 'vert-btn';
btn.title = '행간 연결';
btn.innerHTML = '<svg viewBox="0 0 10 10"><line class="icon-line" x1="5" y1="1" x2="5" y2="9"/></svg>';
btn.addEventListener('click', () => { btn.classList.toggle('active'); drawLines(); });
vc.appendChild(btn);
col.appendChild(vc);
}
const row = document.createElement('div');
row.className = 'node-row';
row.dataset.side = side;
row.dataset.pair = 'false';
row.dataset.idx = idx;
const boxes = document.createElement('div');
boxes.className = 'node-boxes';
const box1 = document.createElement('div');
box1.className = 'box';
box1.textContent = `${pre}${idx}-1`;
boxes.appendChild(box1);
const modeBtn = document.createElement('button');
modeBtn.className = 'mode-btn';
modeBtn.title = '1개 / 2개 전환';
modeBtn.innerHTML = makeModeIcon(false);
modeBtn.addEventListener('click', () => {
const isPair = row.dataset.pair === 'true';
row.dataset.pair = isPair ? 'false' : 'true';
modeBtn.classList.toggle('is-pair', !isPair);
modeBtn.innerHTML = makeModeIcon(!isPair);
if (!isPair) {
boxes.classList.add('is-pair');
const b2 = document.createElement('div');
b2.className = 'box box2';
b2.textContent = `${pre}${row.dataset.idx}-2`;
boxes.appendChild(b2);
} else {
boxes.classList.remove('is-pair');
const b2 = boxes.querySelector('.box2');
if (b2) b2.remove();
}
drawLines();
});
if (side === 'left') {
row.appendChild(boxes);
row.appendChild(modeBtn);
} else {
row.appendChild(modeBtn);
row.appendChild(boxes);
}
col.appendChild(row);
if (redraw) requestAnimationFrame(drawLines);
}
function makeModeIcon(isPair) {
return isPair
? '<svg viewBox="0 0 14 14"><circle class="dot" cx="5" cy="7" r="2.2"/><circle class="dot" cx="9" cy="7" r="2.2"/></svg>'
: '<svg viewBox="0 0 14 14"><circle class="dot" cx="7" cy="7" r="2.5"/></svg>';
}
function removeLastRow(side) {
const col = document.getElementById(side === 'left' ? 'colLeft' : 'colRight');
const rows = col.querySelectorAll('.node-row');
if (rows.length <= 1) return;
const lastRow = rows[rows.length - 1];
const prev = lastRow.previousElementSibling;
if (prev && prev.classList.contains('vert-connector')) prev.remove();
lastRow.remove();
if (side === 'left') leftIdx--; else rightIdx--;
requestAnimationFrame(drawLines);
}
/* ────── 라인 그리기 ────── */
function drawLines() {
const svg = document.getElementById('linesOverlay');
const wrapper = document.getElementById('diagramWrapper');
const center = document.getElementById('centerBox');
svg.querySelectorAll('line.h-line, path.v-path').forEach(el => el.remove());
const wr = wrapper.getBoundingClientRect();
const cr = center.getBoundingClientRect();
const cLeftX = cr.left - wr.left;
const cRightX = cr.right - wr.left;
const cY = cr.top - wr.top + cr.height / 2;
svg.setAttribute('width', wrapper.offsetWidth);
svg.setAttribute('height', wrapper.offsetHeight);
// 수평 라인
document.querySelectorAll('.node-row').forEach(row => {
const side = row.dataset.side;
const boxes = row.querySelector('.node-boxes');
const br = boxes.getBoundingClientRect();
const by = br.top - wr.top + br.height / 2;
if (side === 'left') {
makeLine(svg, br.right - wr.left, by, cLeftX, cY);
} else {
makeLine(svg, cRightX, cY, br.left - wr.left, by);
}
});
// 수직 연결
document.querySelectorAll('.vert-connector').forEach(vc => {
const btn = vc.querySelector('.vert-btn');
if (!btn.classList.contains('active')) return;
const prevRow = vc.previousElementSibling;
const nextRow = vc.nextElementSibling;
if (!prevRow || !nextRow) return;
const prevIsPair = prevRow.dataset.pair === 'true';
const nextIsPair = nextRow.dataset.pair === 'true';
const side = prevRow.dataset.side;
const prevBoxes = prevRow.querySelector('.node-boxes');
const nextBoxes = nextRow.querySelector('.node-boxes');
const prevAllBoxes = prevBoxes.querySelectorAll('.box');
const nextAllBoxes = nextBoxes.querySelectorAll('.box');
if (prevIsPair === nextIsPair) {
// 같은 개수: 중앙에서 중앙으로 직선
const pr = prevBoxes.getBoundingClientRect();
const nr = nextBoxes.getBoundingClientRect();
const mx = (pr.left + pr.right) / 2 - wr.left;
const y1 = pr.bottom - wr.top;
const y2 = nr.top - wr.top;
makeVertPath(svg, mx, y1, mx, y2);
} else if (prevIsPair && !nextIsPair) {
// 2개 → 1개: 위쪽의 안쪽(center에 가까운) 박스 → 아래쪽 단독 박스
// 왼쪽이면 마지막 박스가 안쪽, 오른쪽이면 첫 박스가 안쪽
const srcBox = side === 'left'
? prevAllBoxes[prevAllBoxes.length - 1]
: prevAllBoxes[0];
const dstBox = nextAllBoxes[0];
const sr = srcBox.getBoundingClientRect();
const dr = dstBox.getBoundingClientRect();
const sx = (sr.left + sr.right) / 2 - wr.left;
const sy = sr.bottom - wr.top;
const dx = (dr.left + dr.right) / 2 - wr.left;
const dy = dr.top - wr.top;
makeVertPath(svg, sx, sy, dx, dy);
} else {
// 1개 → 2개: 위쪽 단독 박스 → 아래쪽의 안쪽 박스
const srcBox = prevAllBoxes[0];
const dstBox = side === 'left'
? nextAllBoxes[nextAllBoxes.length - 1]
: nextAllBoxes[0];
const sr = srcBox.getBoundingClientRect();
const dr = dstBox.getBoundingClientRect();
const sx = (sr.left + sr.right) / 2 - wr.left;
const sy = sr.bottom - wr.top;
const dx = (dr.left + dr.right) / 2 - wr.left;
const dy = dr.top - wr.top;
makeVertPath(svg, sx, sy, dx, dy);
}
});
}
function makeLine(svg, x1, y1, x2, y2) {
const line = document.createElementNS(svgNS, 'line');
line.classList.add('h-line');
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
line.setAttribute('marker-end', 'url(#arrowR)');
svg.appendChild(line);
}
function makeVertPath(svg, x1, y1, x2, y2) {
const path = document.createElementNS(svgNS, 'path');
path.classList.add('v-path');
if (Math.abs(x1 - x2) < 2) {
// 거의 같은 X → 직선
path.setAttribute('d', `M${x1},${y1} L${x2},${y2}`);
} else {
// X가 다르면 부드러운 S커브
const midY = (y1 + y2) / 2;
path.setAttribute('d', `M${x1},${y1} C${x1},${midY} ${x2},${midY} ${x2},${y2}`);
}
svg.appendChild(path);
}
/* ────── 초기화 ────── */
function init() {
for (let i = 0; i < 5; i++) addRow('left', false);
for (let i = 0; i < 5; i++) addRow('right', false);
// 데모: 왼쪽 1,3행 → 2개 묶음
const lr = document.querySelectorAll('#colLeft .node-row');
[0, 2].forEach(i => { if (lr[i]) lr[i].querySelector('.mode-btn').click(); });
// 데모: 오른쪽 2,4행 → 2개 묶음
const rr = document.querySelectorAll('#colRight .node-row');
[1, 3].forEach(i => { if (rr[i]) rr[i].querySelector('.mode-btn').click(); });
// 데모: 왼쪽 1-2행 연결 (2개→1개 케이스)
const lv = document.querySelectorAll('#colLeft .vert-btn');
if (lv[0]) lv[0].click();
// 데모: 왼쪽 2-3행 연결 (1개→2개 케이스)
if (lv[1]) lv[1].click();
drawLines();
}
const ro = new ResizeObserver(() => drawLines());
ro.observe(document.getElementById('diagramWrapper'));
window.addEventListener('load', init);
</script>
</body>
</html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flowchart Diagram</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #f4f6f9;
--card: #ffffff;
--border: #e2e6ed;
--border-hover: #b8bfcc;
--text: #2d3748;
--text-sub: #718096;
--accent: #4f6ef7;
--accent-light: #eef1fe;
--line: #b0bac9;
--line-vert: #4f6ef7;
--danger: #e8574f;
--shadow-hover: 0 6px 20px rgba(0,0,0,0.08);
--radius: 10px;
}
body {
font-family: 'Noto Sans KR', sans-serif;
background: var(--bg);
min-height: 100vh;
padding: 32px;
color: var(--text);
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 28px;
flex-wrap: wrap;
align-items: center;
}
.toolbar button {
padding: 8px 18px;
border: 1.5px solid var(--border);
border-radius: 8px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
background: var(--card);
color: var(--text);
}
.toolbar button:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-light);
}
.toolbar button.danger { color: var(--danger); }
.toolbar button.danger:hover {
border-color: var(--danger);
background: #fef2f2;
}
.toolbar .sep {
width: 1px;
height: 28px;
background: var(--border);
margin: 0 4px;
}
/* ── 다이어그램 ── */
.diagram-wrapper {
position: relative;
width: 100%;
}
svg.lines-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 1;
}
svg.lines-overlay line.h-line {
stroke: var(--line);
stroke-width: 1.4;
stroke-dasharray: 5 3;
}
svg.lines-overlay path.v-path {
fill: none;
stroke: var(--line-vert);
stroke-width: 1.4;
stroke-dasharray: 4 3;
}
svg.lines-overlay marker path {
fill: var(--line);
stroke: none;
}
.diagram-grid {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: start;
position: relative;
z-index: 2;
}
.col {
display: flex;
flex-direction: column;
gap: 0;
padding: 16px;
}
/* ── 행 ── */
.node-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.col.left .node-row { justify-content: flex-end; }
.col.right .node-row { justify-content: flex-start; }
.node-boxes {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
border-radius: var(--radius);
border: 1.5px solid transparent;
transition: all 0.2s;
}
.node-boxes.is-pair {
border-color: var(--border);
background: rgba(255,255,255,0.6);
}
.node-boxes.is-pair:hover {
border-color: var(--border-hover);
}
.box {
width: 110px;
height: 58px;
background: var(--card);
border-radius: 8px;
border: 1.5px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 12.5px;
font-weight: 600;
color: var(--text);
transition: all 0.2s;
flex-shrink: 0;
}
.box:hover {
border-color: var(--accent);
box-shadow: var(--shadow-hover);
}
.mode-btn {
width: 26px;
height: 26px;
border-radius: 6px;
border: 1.5px solid var(--border);
background: var(--card);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
z-index: 5;
}
.mode-btn:hover {
border-color: var(--accent);
background: var(--accent-light);
}
.mode-btn .dot { fill: var(--text-sub); }
.mode-btn.is-pair .dot { fill: var(--accent); }
/* ── 행간 연결 버튼 ── */
.vert-connector {
display: flex;
padding: 2px 0;
}
.col.left .vert-connector { justify-content: flex-end; padding-right: 42px; }
.col.right .vert-connector { justify-content: flex-start; padding-left: 42px; }
.vert-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1.5px solid var(--border);
background: var(--card);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
z-index: 5;
}
.vert-btn:hover {
border-color: var(--line-vert);
background: var(--accent-light);
}
.vert-btn.active {
background: var(--line-vert);
border-color: var(--line-vert);
}
.vert-btn .icon-line {
stroke: var(--text-sub);
stroke-width: 2;
}
.vert-btn.active .icon-line { stroke: white; }
/* ── 중앙 ── */
.center-col {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 48px;
align-self: center;
}
.center-box {
width: 140px;
height: 100px;
background: var(--card);
border-radius: 12px;
border: 2px solid var(--border);
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: var(--text);
z-index: 3;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.node-row { animation: slideIn 0.25s ease; }
</style>
</head>
<body>
<div class="toolbar">
<button onclick="addRow('left')">+ 왼쪽 행</button>
<button onclick="addRow('right')">+ 오른쪽 행</button>
<div class="sep"></div>
<button class="danger" onclick="removeLastRow('left')">− 왼쪽 삭제</button>
<button class="danger" onclick="removeLastRow('right')">− 오른쪽 삭제</button>
</div>
<div class="diagram-wrapper" id="diagramWrapper">
<svg class="lines-overlay" id="linesOverlay">
<defs>
<marker id="arrowR" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L7,2.5 L0,5 Z" />
</marker>
</defs>
</svg>
<div class="diagram-grid">
<div class="col left" id="colLeft"></div>
<div class="center-col">
<div class="center-box" id="centerBox">Center</div>
</div>
<div class="col right" id="colRight"></div>
</div>
</div>
<script>
const svgNS = 'http://www.w3.org/2000/svg';
let leftIdx = 0, rightIdx = 0;
function addRow(side, redraw = true) {
const col = document.getElementById(side === 'left' ? 'colLeft' : 'colRight');
const idx = side === 'left' ? ++leftIdx : ++rightIdx;
const pre = side === 'left' ? 'L' : 'R';
const existingRows = col.querySelectorAll('.node-row');
if (existingRows.length > 0) {
const vc = document.createElement('div');
vc.className = 'vert-connector';
const btn = document.createElement('button');
btn.className = 'vert-btn';
btn.title = '행간 연결';
btn.innerHTML = '<svg viewBox="0 0 10 10"><line class="icon-line" x1="5" y1="1" x2="5" y2="9"/></svg>';
btn.addEventListener('click', () => { btn.classList.toggle('active'); drawLines(); });
vc.appendChild(btn);
col.appendChild(vc);
}
const row = document.createElement('div');
row.className = 'node-row';
row.dataset.side = side;
row.dataset.pair = 'false';
row.dataset.idx = idx;
const boxes = document.createElement('div');
boxes.className = 'node-boxes';
const box1 = document.createElement('div');
box1.className = 'box';
box1.textContent = `${pre}${idx}-1`;
boxes.appendChild(box1);
const modeBtn = document.createElement('button');
modeBtn.className = 'mode-btn';
modeBtn.title = '1개 / 2개 전환';
modeBtn.innerHTML = makeModeIcon(false);
modeBtn.addEventListener('click', () => {
const isPair = row.dataset.pair === 'true';
row.dataset.pair = isPair ? 'false' : 'true';
modeBtn.classList.toggle('is-pair', !isPair);
modeBtn.innerHTML = makeModeIcon(!isPair);
if (!isPair) {
boxes.classList.add('is-pair');
const b2 = document.createElement('div');
b2.className = 'box box2';
b2.textContent = `${pre}${row.dataset.idx}-2`;
boxes.appendChild(b2);
} else {
boxes.classList.remove('is-pair');
const b2 = boxes.querySelector('.box2');
if (b2) b2.remove();
}
drawLines();
});
if (side === 'left') {
row.appendChild(boxes);
row.appendChild(modeBtn);
} else {
row.appendChild(modeBtn);
row.appendChild(boxes);
}
col.appendChild(row);
if (redraw) requestAnimationFrame(drawLines);
}
function makeModeIcon(isPair) {
return isPair
? '<svg viewBox="0 0 14 14"><circle class="dot" cx="5" cy="7" r="2.2"/><circle class="dot" cx="9" cy="7" r="2.2"/></svg>'
: '<svg viewBox="0 0 14 14"><circle class="dot" cx="7" cy="7" r="2.5"/></svg>';
}
function removeLastRow(side) {
const col = document.getElementById(side === 'left' ? 'colLeft' : 'colRight');
const rows = col.querySelectorAll('.node-row');
if (rows.length <= 1) return;
const lastRow = rows[rows.length - 1];
const prev = lastRow.previousElementSibling;
if (prev && prev.classList.contains('vert-connector')) prev.remove();
lastRow.remove();
if (side === 'left') leftIdx--; else rightIdx--;
requestAnimationFrame(drawLines);
}
/* ────── 라인 그리기 ────── */
function drawLines() {
const svg = document.getElementById('linesOverlay');
const wrapper = document.getElementById('diagramWrapper');
const center = document.getElementById('centerBox');
svg.querySelectorAll('line.h-line, path.v-path').forEach(el => el.remove());
const wr = wrapper.getBoundingClientRect();
const cr = center.getBoundingClientRect();
const cLeftX = cr.left - wr.left;
const cRightX = cr.right - wr.left;
const cY = cr.top - wr.top + cr.height / 2;
svg.setAttribute('width', wrapper.offsetWidth);
svg.setAttribute('height', wrapper.offsetHeight);
// 수평 라인
document.querySelectorAll('.node-row').forEach(row => {
const side = row.dataset.side;
const boxes = row.querySelector('.node-boxes');
const br = boxes.getBoundingClientRect();
const by = br.top - wr.top + br.height / 2;
if (side === 'left') {
makeLine(svg, br.right - wr.left, by, cLeftX, cY);
} else {
makeLine(svg, cRightX, cY, br.left - wr.left, by);
}
});
// 수직 연결
document.querySelectorAll('.vert-connector').forEach(vc => {
const btn = vc.querySelector('.vert-btn');
if (!btn.classList.contains('active')) return;
const prevRow = vc.previousElementSibling;
const nextRow = vc.nextElementSibling;
if (!prevRow || !nextRow) return;
const prevIsPair = prevRow.dataset.pair === 'true';
const nextIsPair = nextRow.dataset.pair === 'true';
const side = prevRow.dataset.side;
const prevBoxes = prevRow.querySelector('.node-boxes');
const nextBoxes = nextRow.querySelector('.node-boxes');
const prevAllBoxes = prevBoxes.querySelectorAll('.box');
const nextAllBoxes = nextBoxes.querySelectorAll('.box');
if (prevIsPair === nextIsPair) {
// 같은 개수: 중앙에서 중앙으로 직선
const pr = prevBoxes.getBoundingClientRect();
const nr = nextBoxes.getBoundingClientRect();
const mx = (pr.left + pr.right) / 2 - wr.left;
const y1 = pr.bottom - wr.top;
const y2 = nr.top - wr.top;
makeVertPath(svg, mx, y1, mx, y2);
} else if (prevIsPair && !nextIsPair) {
// 2개 → 1개: 위쪽의 안쪽(center에 가까운) 박스 → 아래쪽 단독 박스
// 왼쪽이면 마지막 박스가 안쪽, 오른쪽이면 첫 박스가 안쪽
const srcBox = side === 'left'
? prevAllBoxes[prevAllBoxes.length - 1]
: prevAllBoxes[0];
const dstBox = nextAllBoxes[0];
const sr = srcBox.getBoundingClientRect();
const dr = dstBox.getBoundingClientRect();
const sx = (sr.left + sr.right) / 2 - wr.left;
const sy = sr.bottom - wr.top;
const dx = (dr.left + dr.right) / 2 - wr.left;
const dy = dr.top - wr.top;
makeVertPath(svg, sx, sy, dx, dy);
} else {
// 1개 → 2개: 위쪽 단독 박스 → 아래쪽의 안쪽 박스
const srcBox = prevAllBoxes[0];
const dstBox = side === 'left'
? nextAllBoxes[nextAllBoxes.length - 1]
: nextAllBoxes[0];
const sr = srcBox.getBoundingClientRect();
const dr = dstBox.getBoundingClientRect();
const sx = (sr.left + sr.right) / 2 - wr.left;
const sy = sr.bottom - wr.top;
const dx = (dr.left + dr.right) / 2 - wr.left;
const dy = dr.top - wr.top;
makeVertPath(svg, sx, sy, dx, dy);
}
});
}
function makeLine(svg, x1, y1, x2, y2) {
const line = document.createElementNS(svgNS, 'line');
line.classList.add('h-line');
line.setAttribute('x1', x1);
line.setAttribute('y1', y1);
line.setAttribute('x2', x2);
line.setAttribute('y2', y2);
line.setAttribute('marker-end', 'url(#arrowR)');
svg.appendChild(line);
}
function makeVertPath(svg, x1, y1, x2, y2) {
const path = document.createElementNS(svgNS, 'path');
path.classList.add('v-path');
if (Math.abs(x1 - x2) < 2) {
// 거의 같은 X → 직선
path.setAttribute('d', `M${x1},${y1} L${x2},${y2}`);
} else {
// X가 다르면 부드러운 S커브
const midY = (y1 + y2) / 2;
path.setAttribute('d', `M${x1},${y1} C${x1},${midY} ${x2},${midY} ${x2},${y2}`);
}
svg.appendChild(path);
}
/* ────── 초기화 ────── */
function init() {
for (let i = 0; i < 5; i++) addRow('left', false);
for (let i = 0; i < 5; i++) addRow('right', false);
// 데모: 왼쪽 1,3행 → 2개 묶음
const lr = document.querySelectorAll('#colLeft .node-row');
[0, 2].forEach(i => { if (lr[i]) lr[i].querySelector('.mode-btn').click(); });
// 데모: 오른쪽 2,4행 → 2개 묶음
const rr = document.querySelectorAll('#colRight .node-row');
[1, 3].forEach(i => { if (rr[i]) rr[i].querySelector('.mode-btn').click(); });
// 데모: 왼쪽 1-2행 연결 (2개→1개 케이스)
const lv = document.querySelectorAll('#colLeft .vert-btn');
if (lv[0]) lv[0].click();
// 데모: 왼쪽 2-3행 연결 (1개→2개 케이스)
if (lv[1]) lv[1].click();
drawLines();
}
const ro = new ResizeObserver(() => drawLines());
ro.observe(document.getElementById('diagramWrapper'));
window.addEventListener('load', init);
</script>
</body>
</html>
답변에 대한 댓글 4개
AI 잡고 씨름하거나 의뢰각 질문 같네요.
난이도가 퍼블리셔나 개발자 님들 밥줄 수준이라..
질게에 코드 올라올 수준의 질문이 아닌 듯 합니다.
난이도가 퍼블리셔나 개발자 님들 밥줄 수준이라..
질게에 코드 올라올 수준의 질문이 아닌 듯 합니다.
답변에 대한 댓글 2개
답변을 작성하려면 로그인이 필요합니다.
위 html 코드를 한번 실행해보시겠습니까?