이런 모양에 플로우차트 다이어그램은 어떻게 퍼블리싱 해야 할까요 궁금한건 라인을 어떻게..

4주 전 306
 

이런 모양에 플로우차트 다이어그램은 어떻게 퍼블리싱 해야 할까요 궁금한건 라인을 어떻게.. 저렇게 만들지랑..

좌우 갯수는 유동적이라 박스가 생길때마다 저렇게 선을 이어줘야하는데..

방법을 잘모르겠습니다



1770849101_CROplmeqgu.webp
|

답변 5개 / 댓글 10개

<!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>

답변에 대한 댓글 4개

아 스크래핑이 아니라, 퍼블리싱이었군요. 여러가지 방법이 있지만 현실적으로 가장 동적으로 처리 가능한 방법은 SVG + Javascript 조합입니다. 저 구조로 할때가 동적으로 박스가 추가/삭제될 때 좌표 계산해서 선을 그리기 가장 유연합니다. <line>이나 <path>로 그리면 되고, 화살표도 <marker>로 처리 가능하죠.

위 html 코드를 한번 실행해보시겠습니까?
네 한번 테스트해보겠습니다 이걸하기 위해서 svg를 따로 공부해야할까요?
네 ai한테 제가 올린 코드에 상세하게 주석 달아달라하시고, svg 기본 사용법 가르쳐달라고 하시면 이해되실겁니다. 근데 저 꽤 성의있게 답변한거같은데 채택 해주실수 있을까요 ㅋㅋㅋ
AI 잡고 씨름하거나 의뢰각 질문 같네요.
난이도가 퍼블리셔나 개발자 님들 밥줄 수준이라..
질게에 코드 올라올 수준의 질문이 아닌 듯 합니다.

답변에 대한 댓글 1개

그런가요... 감사합니다.

답변에 대한 댓글 1개

이게 뭐에요..?
힘들껄요 노가다 고~ svg로 처리 해야할듯요

답변에 대한 댓글 2개

svg 어떤 걸 공부해야 가능할가요?
저거 원본을 보고 싶은데, URL 주소 있으신가요?

답변에 대한 댓글 2개

원본없고 제가 이미지로 그린거입니다 ㅠㅜㅠ
아 스크래핑이 아니라, 퍼블리싱이었군요. 여러가지 방법이 있지만 현실적으로 가장 동적으로 처리 가능한 방법은 SVG + Javascript 조합입니다. 저 구조로 할때가 동적으로 박스가 추가/삭제될 때 좌표 계산해서 선을 그리기 가장 유연합니다. <line>이나 <path>로 그리면 되고, 화살표도 <marker>로 처리 가능하죠.

답변을 작성하려면 로그인이 필요합니다.