이런 모양에 플로우차트 다이어그램은 어떻게 퍼블리싱 해야 할까요 궁금한건 라인을 어떻게..
이런 모양에 플로우차트 다이어그램은 어떻게 퍼블리싱 해야 할까요 궁금한건 라인을 어떻게.. 저렇게 만들지랑..
좌우 갯수는 유동적이라 박스가 생길때마다 저렇게 선을 이어줘야하는데..
방법을 잘모르겠습니다
|
답변 5개 / 댓글 10개
3주 전
<!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개
3주 전
그누보드초보이용자
3주 전
네 한번 테스트해보겠습니다 이걸하기 위해서 svg를 따로 공부해야할까요?
3주 전
네 ai한테 제가 올린 코드에 상세하게 주석 달아달라하시고, svg 기본 사용법 가르쳐달라고 하시면 이해되실겁니다. 근데 저 꽤 성의있게 답변한거같은데 채택 해주실수 있을까요 ㅋㅋㅋ
1주 전
????
AI 잡고 씨름하거나 의뢰각 질문 같네요.
난이도가 퍼블리셔나 개발자 님들 밥줄 수준이라..
질게에 코드 올라올 수준의 질문이 아닌 듯 합니다.
난이도가 퍼블리셔나 개발자 님들 밥줄 수준이라..
질게에 코드 올라올 수준의 질문이 아닌 듯 합니다.
답변에 대한 댓글 1개
답변에 대한 댓글 1개
3주 전
힘들껄요 노가다 고~ svg로 처리 해야할듯요
답변에 대한 댓글 2개
4주 전
저거 원본을 보고 싶은데, URL 주소 있으신가요?
답변에 대한 댓글 2개
3주 전
아 스크래핑이 아니라, 퍼블리싱이었군요. 여러가지 방법이 있지만 현실적으로 가장 동적으로 처리 가능한 방법은 SVG + Javascript 조합입니다. 저 구조로 할때가 동적으로 박스가 추가/삭제될 때 좌표 계산해서 선을 그리기 가장 유연합니다. <line>이나 <path>로 그리면 되고, 화살표도 <marker>로 처리 가능하죠.
답변을 작성하려면 로그인이 필요합니다.
위 html 코드를 한번 실행해보시겠습니까?