과제 소개와 블로그 글에 구조를 설명하는 그림이 필요했습니다. 흐름을 글로만 적으면 읽는 사람이 구조를 스스로 재구성해야 합니다. 순서도 문법은 mermaid 가 사실상 표준이라 원문 표기는 그대로 쓰기로 했고, 남은 결정은 렌더링 방식뿐이었습니다.
라이브러리를 쓰지 않은 이유
이 사이트는 빌드 결과인 정적 파일만 올립니다. 순서도 라이브러리를 추가하면 그림 하나 때문에 브라우저에서만 실행되는 코드가 함께 들어옵니다. 여기서 세 가지 문제가 생깁니다.
- 정적 HTML 에 그림이 없습니다. 스크립트가 실행되어야 그려지므로 첫 화면에 빈 자리가 남고, 자바스크립트를 끄면 계속 비어 있습니다.
- 테마를 따로 관리해야 합니다. 이 사이트의 색은 전부 CSS 변수입니다. 라이브러리가 그린 그림은 라이브러리 자체 설정에서 색을 받으므로 라이트·다크 설정이 이중이 됩니다.
- 실제로 쓰는 문법은 일부입니다. 시퀀스 다이어그램도 간트 차트도 쓰지 않습니다. 필요한 것은
flowchart하나입니다.
그래서 이 사이트에서 마크다운 파서와 코드 구문 강조를 직접 구현한 것과 같은 방식을 택했습니다. 원문 문법은 mermaid 를 따르되 렌더링만 직접 구현하는 것입니다.
지원 범위를 먼저 정했다
부분 구현에서 가장 중요한 결정은 어디까지 지원하지 않을지입니다. 문법 전체를 처리하려 하면 라이브러리를 다시 만드는 일이 됩니다. 실제로 콘텐츠에서 쓰는 문법만 남겼습니다.
| 읽는다 | 읽지 않는다 | |
|---|---|---|
| 도표 종류 | flowchart TD·TB·LR(graph 도 같음) | 시퀀스·간트·클래스·상태 |
| 방향 | TD·LR | RL·BT 는 각각 LR·TD 로 변환 |
| 노드 모양 | 사각·둥근·타원·원·마름모·육각·원통·서브루틴·비대칭 9종 | 그 밖의 모양 |
| 간선 | -->·---·-.->·==>, 라벨 두 형태, 연속 연결, & 다중 연결 | 화살표 머리 모양 지정 |
| 서브그래프 | subgraph … end, 중첩 | |
| 스타일 지정 | style·classDef·click 은 줄 단위로 무시 |
원문은 mermaid 문법 그대로입니다. 나중에 라이브러리로 바꾸더라도 콘텐츠는 수정할 것이 없습니다. 직접 구현으로 감당하기 어려워질 때를 대비한 선택입니다.
파싱: 줄 단위 정규식의 한계
처음에는 줄마다 정규식으로 A --> B 를 뽑으면 될 것으로 생각했습니다. 이 방식은 곧 실패합니다. 라벨 안에 화살표 기호가 들어갈 수 있기 때문입니다. 결국 현재 위치를 유지하며 앞에서부터 읽는 작은 스캐너를 구현했습니다. 노드 모양을 판별하는 표는 다음과 같습니다.
/** 여는 괄호가 긴 것부터 견주어야 `[[`·`([` 가 `[`·`(` 로 잘못 읽히지 않는다. */
const shapeBrackets: readonly [string, string, NodeShape][] = [
["[[", "]]", "subroutine"],
["[(", ")]", "cylinder"],
["([", "])", "stadium"],
["((", "))", "circle"],
["{{", "}}", "hexagon"],
["[", "]", "rect"],
["(", ")", "round"],
["{", "}", "diamond"],
[">", "]", "flag"],
];이 표의 순서 자체가 규칙입니다. [ 를 먼저 비교하면 [[ 와 [( 가 사각형으로 인식되고, 남은 괄호 한 짝이 라벨 문구에 섞여 들어갑니다. 여는 괄호가 긴 것부터 비교해야 합니다. 같은 이유로 라벨이 따옴표로 감싸여 있는지 먼저 확인하고, 감싸여 있으면 닫는 따옴표까지를 라벨 문구로 처리합니다.
DOM 없이 글자 폭 추정하기
그림은 빌드 시점에 만들어집니다. Node 에서 실행되는 프리렌더에는 글자 폭을 측정할 DOM 도 캔버스도 없습니다. 그런데 상자 크기를 정하려면 글자 폭이 필요합니다. 그래서 문자 종류별 계수로 추정했습니다.
/** 글자 폭 추정. 한글·한자는 정사각형에 가깝고 라틴 문자는 그 절반 남짓이다. */
export function textWidth(text: string, size: number = diagramFont.size): number {
let width = 0;
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
if (code === 0x20) width += 0.3;
else if (code >= 0x1100) width += 0.98;
else if (/[A-Z0-9]/.test(ch)) width += 0.64;
else if (/[a-z]/.test(ch)) width += 0.54;
else width += 0.42;
}
return width * size;
}한글 라벨이 대부분이라 이 정도 정확도로 충분합니다. 오차가 커지는 경우는 라틴 문자가 길게 이어질 때입니다. 상자가 실제 필요한 것보다 좁게 잡히므로, 그런 라벨은 <br/> 로 줄을 나누는 편이 안전합니다. 글자 크기와 줄 간격은 CSS 값과 한 곳에서 맞춰 둡니다. 두 값이 어긋나면 글자가 상자 밖으로 나갑니다.
배치: 층 단위 좌표 계산
노드 위치는 층(rank) 단위로 정합니다. 간선을 따라가며 가장 긴 경로 길이를 층 번호로 삼으면 화살표가 대체로 한 방향을 향합니다. 같은 층의 노드를 가로로 배열하고 층 사이를 일정 간격으로 두면 기본 배치가 완성됩니다.
여기서 주의할 점이 하나 있습니다. 되돌아가는 간선이 있으면(재시도·반복) 층 계산이 종료되지 않습니다. 깊이 우선 탐색으로 순회하면서 아직 탐색 중인 노드로 돌아가는 간선을 순환 간선으로 표시해 두고, 층을 계산할 때는 그 간선을 제외합니다. 그림에서는 그 간선만 바깥으로 우회시켜 그립니다.
- 서브그래프(
subgraph)는 고정 폭의 열(TD)이나 행(LR)을 하나씩 차지합니다. 상자끼리 겹치지 않게 하는 가장 단순한 방법입니다. - 서브그래프에 속하지 않은 노드는 그 층에 서브그래프가 없으면 가운데에, 있으면 별도 열에 배치합니다.
- 서브그래프 밖에서 먼저 등장한 노드라도 서브그래프 안에서 다시 나오면 그 서브그래프 소속으로 변경합니다. 원문에서 노드를 위쪽에 한 번 선언하고 아래 서브그래프에서 쓰는 경우가 흔합니다.
문법 오류를 드러내는 방법
순서도 원문은 콘텐츠 파일에 사람이 직접 적으므로 오타가 납니다. 이때 그림이 아무 표시 없이 사라지면 어디가 잘못됐는지 알 수 없습니다. 그래서 원문과 오류 사유를 함께 상자로 출력합니다. 그림이 나오지 않은 자리에 빈칸 대신 설명이 남습니다.
그리고 이 오류 상자가 실사이트로 나가지 않도록 빌드 후에 개수를 확인합니다. 산출물 전체에서 오류 상자의 클래스 이름을 세어 0 인지 보면 됩니다.
grep -c diagram__error out/**/index.html구현하면서 확인한 제약
- 서브그래프 id 를 간선 끝점으로 쓰면 일반 노드로 인식합니다. mermaid 는 서브그래프 자체를 화살표로 연결할 수 있지만 이 렌더러는 지원하지 않습니다. 서브그래프 안의 실제 노드를 지정해야 합니다.
- 색 지정 문법을 무시합니다.
style·classDef를 읽지 않으므로 색은 전부 CSS 토큰에서 옵니다. 라이트·다크가 자동으로 맞는 대신, 특정 노드만 강조하려면 렌더러에 규칙을 추가해야 합니다. - 서브그래프 제목 높이를 작게 잡으면 상자가 이전 층을 가립니다. 중첩 서브그래프에서 먼저 확인했습니다. 층 간격과 제목 높이를 한 곳에 모아 두고 함께 조정했습니다.
직접 구현의 득실
| 라이브러리 | 직접 그리기 | |
|---|---|---|
| 첫 화면 | 스크립트 실행 후 그려짐 | HTML 안에 SVG 가 이미 있음 |
| 브라우저 코드 | 번들 청크 추가 | 없음 |
| 테마 | 라이브러리 설정에서 따로 | 사이트 CSS 토큰 그대로 |
| 문법 범위 | 전부 | flowchart 의 일부 |
| 유지 비용 | 라이브러리 업데이트 | 새 문법이 필요하면 직접 구현 |
도표 문법 전체를 지원하지 않는다면 파서와 배치 계산은 생각보다 간단합니다. 어려운 쪽은 문법 처리가 아니라 지원하지 않을 범위를 정하는 일이었습니다. 지원 범위를 좁게 고정하면 나머지는 좌표 계산이고, 그 결과가 정적 HTML 안에 그대로 남습니다.