NodeRenderer: 타입별 컴포넌트 매핑

2편에서 만든 파서가 JSON AST를 뱉으면, 이제 그걸 화면에 그려야 한다. nodes 배열의 각 노드를 타입에 따라 다른 UI로 렌더링하는 게 NodeRenderer의 역할이다.

function NodeRenderer({ node }) {
  switch (node.type) {
    case 'timeline':  return <TimelineView ... />;
    case 'sequence':  return <SequenceView ... />;
    case 'hierarchy': return <HierarchyView ... />;
    case 'compare':   return <CompareView ... />;
    case 'kv':        return <KVView ... />;
    case 'list':      return <ListView ... />;
    case 'section':   return <SectionView ... />;
    case 'box':       return <BoxView ... />;
    case 'tree':      return <TreeView ... />;
    case 'columns':   return <ColumnsView ... />;  // 재귀
    case 'branch':    return <BranchView ... />;
    // ...
  }
}

11가지 타입이 각각 다른 렌더링 로직을 가진다. 실제로는 하나의 NodeRenderer 함수 안에 switch문으로 전부 들어있다. 컴포넌트를 분리하지 않은 이유는, 각 타입의 렌더링이 테마 색상 객체(c)를 공유하고 있어서 props 전달이 번거롭기 때문이다.

columns 타입은 특별하다. 자식 노드들을 flex 가로 배치하면서 각 자식을 다시 NodeRenderer로 재귀 렌더링한다:

case 'columns':
  return (
    <div style={{ display: 'flex', gap: 10 }}>
      {node.children?.map((child, i) => (
        <div key={i} style={{ flex: 1 }}>
          <NodeRenderer node={{...child, type: child.type || 'box'}} />
        </div>
      ))}
    </div>
  );

렌더링 기법 몇 가지

타임라인: 세로 라인 + 원형 마커

타임라인은 왼쪽에 세로 라인을 깔고, 각 항목에 원형 마커를 붙이는 구조다.

// 세로 라인 (absolute)
<div style={{
  position: 'absolute', left: 6, top: 8, bottom: 8,
  width: 2,
  background: 'linear-gradient(to bottom, #3b82f6, #8b5cf6)',
}} />

// 각 항목의 원형 마커 (absolute, 라인 위에 겹침)
<div style={{
  position: 'absolute', left: -17, top: 6,
  width: 12, height: 12, borderRadius: '50%',
  background: '#3b82f6',
  border: '2px solid #1e3a5f',
}} />

세로 라인은 부모 div에 absolute로 고정하고, 각 마커는 음수 left로 라인 위에 정확히 겹친다. ... 생략 표시가 있는 항목은 마커 크기를 작게(8px) 하고 색상을 회색으로 바꿔서 시각적으로 구분한다.

시퀀스: 참여자 헤더 + 방향별 화살표

시퀀스 다이어그램은 상단에 참여자 뱃지를 놓고, 사이에 세로 라인을 그린 뒤, 각 메시지를 방향에 따라 다른 색상의 화살표로 표현한다.

  • 오른쪽 방향(): 주황색 뱃지 + 문자
  • 왼쪽 방향(): 시안색 뱃지 + 문자

라벨은 inline-block 뱃지로 만들고 z-index: 2를 줘서 세로 라인 위에 떠 보이게 한다.

비교: 결과 박스 + CSS clipPath 화살표

비교 다이어그램의 결과 영역은 positive 속성에 따라 초록/빨강 배경을 자동 적용한다. 가운데 화살표는 CSS clipPath로 그린다:

clipPath: 'polygon(0 35%, 70% 35%, 70% 0, 100% 50%, 70% 100%, 70% 65%, 0 65%)'

7개의 꼭짓점으로 오른쪽을 가리키는 화살표 모양을 만든다. 이미지 없이 순수 CSS만으로 화살표를 표현할 수 있다.

계층: 연결선 위치 계산

계층 다이어그램에서 자식 카드들의 위치는 균등 분배로 계산한다:

const pos = ((i + 0.5) / children.length) * 100; // 퍼센트

자식이 3개면 각각 16.7%, 50%, 83.3% 위치에 세로선이 내려온다. 수평선은 첫 번째 자식부터 마지막 자식까지 이어지고, 각 자식 위치에서 화살표가 아래를 가리킨다.

테마 시스템

8개의 시맨틱 색상 슬롯

각 테마는 8개의 색상 슬롯을 가진다:

const theme = {
  bg:       // 전체 배경 (그라데이션 or 단색)
  cardBg:   // 카드 배경
  headerBg: // 카드 헤더 (그라데이션)
  text:     // 주 텍스트
  subText:  // 보조 텍스트
  accent:   // 강조색
  itemBg:   // 아이템 행 배경
  border:   // 테두리
};

렌더러는 이 8개 슬롯만 참조한다. 테마를 바꾸면 슬롯의 값만 교체되고, 렌더링 로직은 그대로다.

6개 테마의 설계 의도

테마배경헤더용도
Dark#12121a 그라데이션앰버→오렌지기본 다크 모드
Light#f8fafc 단색블루 그라데이션밝은 환경
Solar#1a1a2e 그라데이션앰버→오렌지에너지/태양광 주제
AWS#0f1419 그라데이션오렌지 #ff9900AWS 브랜드
Azure#0a0f1a 그라데이션블루 #0078d4Azure 브랜드
Minimal#fafafa 단색블랙 #18181b인쇄/문서용

Light와 Minimal만 라이트 팔레트를 사용하고, 나머지 4개는 다크 팔레트를 사용한다.

20색 팔레트

노드의 color 속성에 따라 적용되는 색상 팔레트가 20가지다. 각 색상은 4가지 변형을 가진다:

const colors = {
  blue: {
    bg:     'rgba(59,130,246,0.1)',   // 배경 (10% 투명도)
    border: 'rgba(59,130,246,0.3)',   // 테두리 (30% 투명도)
    text:   '#93c5fd',                // 텍스트 (밝은 톤)
    accent: '#3b82f6',                // 강조 (원색)
  },
  green: { ... },
  red: { ... },
  // ... 총 20색
};

20색 목록: blue, green, red, orange, purple, gray, cyan, teal, emerald, lime, yellow, amber, pink, rose, indigo, violet, sky, slate, zinc, stone.

다크 테마와 라이트 테마의 차이는 text 값뿐이다. 다크에서는 밝은 파스텔(#93c5fd)로 어두운 배경 위 가독성을 확보하고, 라이트에서는 어두운 톤(#1d4ed8)으로 밝은 배경 위 가독성을 확보한다. bg, border, accent는 rgba 투명도로 처리해서 양쪽 테마 모두 호환된다.

색상 자동 할당

파서 단계에서 노드의 내용을 분석해 색상을 자동으로 지정한다:

if (title.includes('무료')) color = 'green';
else if (items.length > 0) color = 'orange';
else if (cost) color = 'purple';
// 기본값: orange

“무료” 키워드가 있으면 초록색, 불릿 아이템이 있으면 주황색, 비용이 표시되면 보라색. 렌더러에서는 colors[node.color] || colors.orange로 조회해서, 색상이 지정되지 않은 노드도 기본 주황색으로 표시된다.

JSON을 직접 편집해서 "color": "cyan"으로 바꾸면 해당 노드만 시안색으로 바뀐다. 자동 할당이 마음에 들지 않을 때 수동 오버라이드가 가능한 구조다.

3-Column 에디터의 양방향 데이터 흐름

에디터의 세 패널(ASCII, JSON, Card)은 editMode 상태로 데이터 흐름을 제어한다:

const [editMode, setEditMode] = useState('ascii');

// ASCII 편집 → JSON + Card 자동 갱신
useEffect(() => {
  if (editMode === 'ascii') {
    const parsed = parseAsciiToJson(ascii);
    setData(parsed);
    setJson(JSON.stringify(parsed, null, 2));
  }
}, [ascii, editMode]);

// JSON 편집 → Card만 갱신 (ASCII는 유지)
const handleJsonChange = (text) => {
  setJson(text);
  setEditMode('json');
  try {
    const parsed = JSON.parse(text);
    setData(parsed);    // Card 갱신
  } catch (e) {
    setError(e.message); // 에러 표시
  }
};

editMode가 “현재 소스가 어느 패널인지"를 추적한다. ASCII 편집 중이면 JSON이 자동으로 덮어쓰여야 하고, JSON 편집 중이면 ASCII가 건드려지면 안 된다. 이 단순한 상태 하나로 양방향 편집의 충돌을 방지한다.

JSON 파싱 에러가 발생하면 배경색이 붉은 톤(#0a0505)으로 바뀌고 아이콘이 표시된다. 에러 상태에서도 이전 카드는 유지되어서, JSON을 고치는 동안 마지막 정상 상태의 카드를 계속 볼 수 있다.

폰트 선택

ASCII 에디터와 JSON 에디터는 다른 폰트를 사용한다:

  • ASCII 에디터: D2Coding — 한글/영문 1:2 폭 비율을 맞춘 코딩 폰트. ASCII 아트의 줄 맞춤이 폰트에 의존하기 때문에, 한글이 정확히 영문 2칸을 차지하는 폰트가 필수다. 일반 고정폭 폰트는 한글 폭이 달라서 표가 어긋난다.
  • JSON 에디터: JetBrains Mono — JSON은 영문 전용이므로 일반 코딩 폰트를 사용한다.
  • 카드 UI: Noto Sans KR — 본문용 가변폭 한글 폰트.

CSS-in-JS 인라인 스타일

ascii2card는 별도 CSS 파일이나 styled-components 없이 전부 인라인 스타일로 처리한다.

<div style={{
  background: c.bg,
  border: `1px solid ${c.border}`,
  borderRadius: 10,
  padding: 14,
}}>

이렇게 한 이유는, 테마 색상이 JavaScript 객체로 관리되기 때문이다. 테마를 전환하면 c 객체의 참조만 바뀌고, React가 스타일을 다시 적용한다. CSS 클래스명을 동적으로 생성하거나, CSS 변수를 업데이트하는 것보다 직관적이다.

트레이드오프는 있다. 파일이 1,362줄로 커졌고, 같은 스타일 패턴이 여러 곳에서 반복된다. 하지만 단일 파일로 완결되어 의존성이 없고, 복사-붙여넣기만으로 다른 프로젝트에 가져다 쓸 수 있다는 장점이 크다.

시리즈를 마치며

3편에 걸쳐 ascii2card의 전체 구조를 다뤘다.

  • 1편: 동기, 전체 파이프라인, 8가지 다이어그램 타입
  • 2편: 타입 감지 우선순위, 14단계 줄 단위 파서, 7가지 파싱 기법
  • 3편: NodeRenderer 매핑, 테마 시스템, 20색 팔레트, 에디터 UX

생성형 AI와의 대화에서 나온 ASCII 다이어그램을 복사-붙여넣기 한 번으로 카드 UI로 바꿀 수 있는 도구를 만들었다. 파서를 v1에서 v12까지 진화시키면서 8가지 패턴을 자동 인식하게 됐고, 6개 테마와 20색 팔레트로 다양한 분위기의 카드를 만들 수 있다.

ASCII 아트를 그냥 버리기 아까웠던 분들에게 도움이 됐으면 좋겠다.