
Blog Figure
- 4 installs
- Updated July 11, 2026
- october-academy/agent-plugins
Generates a static PNG figure for a blog post in a Neo-Brutalism style using one of 30 diagram patterns, then inserts it into the MDX or Markdown file.
About
This skill turns a blog concept into a Neo-Brutalism styled PNG figure by rendering HTML in a browser and capturing a screenshot, choosing from 30 diagram and chart patterns. A developer uses it to add diagrams, charts, or infographics to a blog MDX/Markdown post.
- 30 figure patterns from flow to charts to infographics
- Renders HTML to PNG and inserts the image into the doc
Blog Figure by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,139 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/october-academy/agent-plugins --skill blog-figureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| Last updated | July 11, 2026 |
| Repository | october-academy/agent-plugins ↗ |
What it does
Generates a static PNG figure for a blog post in a Neo-Brutalism style using one of 30 diagram patterns, then inserts it into the MDX or Markdown file.
Files
Blog Figure Generator
Generate Neo-Brutalism styled figure images for blog posts: HTML → browser → PNG.
Workflow
1. Understand context: Read blog MDX/MD or user description to decide what to visualize 2. Content Brief: Extract the core concept to visualize and present to user for confirmation (see Content Brief below) 3. Suggest patterns: Based on the confirmed brief, pick the 4 most fitting patterns from 30 available, and present them via AskUserQuestion with ASCII art previews (see Pattern Selection below) 4. Create HTML: Before writing HTML, read references/design-rules.md for all design constraints. Write standalone HTML to /tmp/blog-figure-{name}.html linking assets/figure.css 5. Capture PNG: Open in browser, screenshot at 1440×810, save PNG 6. Save to project: Move PNG to apps/content/src/content/blog/images/{slug}/ 7. Insert into document: If user provided a .md or .mdx file, insert the image tag at the contextually correct location (see Document Insertion below) 8. Verify: Read the saved PNG to visually confirm
Content Brief
패턴을 고르기 전에, 블로그에서 시각화할 핵심 개념을 추출하여 사용자에게 확인받는다. Figure의 내용이 블로그와 동떨어지거나 너무 추상적이 되는 것을 방지하는 핵심 단계.
추출 항목
| 항목 | 설명 | 예시 |
|---|---|---|
| 핵심 메시지 | 이 Figure가 전달해야 할 한 문장 | "사용자가 말하는 것과 실제 행동은 다르다" |
| 키워드 | Figure에 실제로 들어갈 단어 3~5개 | "좋은데요", "0건", "말 vs 행동" |
| 구조 | 개념 간 관계 유형 | 대비(A vs B), 순서(A→B→C), 계층(A⊃B), 순환(A↻B) |
| 강조점 | 보는 사람이 가장 먼저 인식해야 할 것 | "0건이라는 숫자의 충격" |
추출 방법
블로그 글에서 다음을 찾는다:
1. 글의 핵심 주장/결론 — 제목, 서론 마지막 문장, 결론 첫 문장에서 발견됨 2. 구체적 사례/데이터 — 추상적 개념보다 구체적 숫자, 인용, 사례가 Figure에 적합 3. 대비/전환 구조 — "하지만", "반면", "이전에는 ~했지만 지금은", "X가 아니라 Y" 같은 전환점 4. 독자의 Aha moment — 글을 읽다가 "오" 하고 멈칫할 지점. 그것이 Figure의 존재 이유
피해야 할 것
- 글의 목차를 그대로 나열 (Figure는 목차가 아님)
- 추상적 키워드만 나열 ("전략", "실행", "성과" → 어떤 글에나 끼워넣을 수 있으면 나쁜 Brief)
- 글 전체를 요약하려는 시도 (Figure는 글의 한 장면을 포착할 뿐)
사용자 확인
AskUserQuestion으로 2~3가지 시각화 방향을 제시한다. 각 옵션은 "이 글에서 무엇을 figure로 만들지"에 대한 서로 다른 해석이다.
AskUserQuestion({
questions: [{
question: "어떤 장면을 Figure로 만들까요?",
header: "Content Brief",
multiSelect: true,
options: [
{
label: "해석 A: {1줄 핵심 메시지}",
description: "키워드: {단어1}, {단어2}, {단어3}",
markdown: "**구조**: {관계 유형}\n**강조점**: {가장 눈에 띄어야 할 것}\n**근거**: 블로그에서 이 부분이 Figure로 적합한 이유 1줄"
},
{
label: "해석 B: {1줄 핵심 메시지}",
description: "키워드: {단어1}, {단어2}, {단어3}",
markdown: "**구조**: {관계 유형}\n**강조점**: {가장 눈에 띄어야 할 것}\n**근거**: 블로그에서 이 부분이 Figure로 적합한 이유 1줄"
},
{
label: "해석 C: {1줄 핵심 메시지}",
description: "키워드: {단어1}, {단어2}, {단어3}",
markdown: "**구조**: {관계 유형}\n**강조점**: {가장 눈에 띄어야 할 것}\n**근거**: 블로그에서 이 부분이 Figure로 적합한 이유 1줄"
}
]
}]
})중요: 각 해석은 글의 서로 다른 부분/관점을 포착해야 한다. 같은 내용을 다른 말로 바꾼 3개가 아니라, 진짜로 다른 장면 3개를 제시하라.
Content Brief → Pattern Selection 연결
사용자가 Brief를 확인하면, 그 Brief의 구조가 패턴 선택을 자연스럽게 좁힌다:
| Brief 구조 | 적합한 패턴 (우선순위) |
|---|---|
| 대비 (A vs B) | Comparison, Flow (split), Matrix, Network |
| 순서 (A→B→C) | Flow, Journey, Timeline, Storyboard |
| 계층 (A⊃B⊃C) | Architecture, Hierarchy, Isometric, Schema |
| 순환 (A↻B) | Loop, State, Graph |
| 수치 비교 | Data Viz, Funnel, Timeline, Waffle, Dumbbell, Bullet |
| 비율/구성비 | Waffle, Treemap, Funnel |
| 다차원 평가 | Radar, Matrix |
| 순위 변화 | Slope, Data Viz |
| 밀도/빈도 | Heatmap, Waffle |
| 실적/목표 | Bullet, Data Viz |
| 트렌드 요약 | Sparkline Grid, Data Viz |
| 증감 분해 | Waterfall, Data Viz |
| 인용/선언 | Typographic Statement |
| 상호작용 | Interaction, Terminal, IconDiagram, Storyboard |
| 추상/공간 구조 | Isometric, Network, Graph |
| 시스템 연결 | IconDiagram, Architecture, Interaction |
Pattern Selection
After understanding context, use AskUserQuestion to let the user pick from the 4 most relevant patterns. Each option MUST include a markdown field with an ASCII art preview showing the pattern's layout structure.
How to pick the 4 patterns
Analyze the user's content and rank all 30 patterns by relevance:
- Comparison: X vs Y, 좌우 대비, before/after
- Flow: 프로세스, 단계별 차이, 방법론
- Timeline: 시간 배분, 비율, 순서
- Concept: 관계도, 벤 다이어그램, 개념
- Architecture: 시스템 구성, 레이어, 컴포넌트
- Interaction: 시퀀스, 요청/응답, API 플로우
- State: 상태 전이, 라이프사이클
- Schema: DB 모델, 엔티티 관계
- Hierarchy: 트리, 조직도, 분류
- Matrix: 2x2 분석, 비교표
- Journey: 사용자 여정, 터치포인트
- Funnel: 전환율, 단계별 감소
- Loop: 순환 프로세스, 피드백
- Data Viz: 수치 비교, 바 차트
- Storyboard: 시나리오, 단계별 장면
- Terminal: CLI 시각화, 터미널 UI, 도구 사용 장면
- Isometric: 3D 블록, 와이어프레임, 레이어 구조 [SVG]
- IconDiagram: 시스템 다이어그램, 아이콘 연결, 기술 구성도 [SVG]
- Network: 노드 네트워크, 결정론↔확률 대비, 추상 관계 [Canvas]
- Graph: 포스-다이렉티드 그래프, 노드-링크 자동 레이아웃 [D3]
- Waffle: 비율 체감, 퍼센트 시각화, 100칸 중 N칸 [SVG]
- Typographic Statement: 에디토리얼 인용, 핵심 정의, 선언적 메시지 [SVG]
- Slope: 전후 순위 변화, 랭킹 이동, 두 시점 비교 [SVG]
- Treemap: 면적 비례 구성비, 카테고리 분포 [D3]
- Radar: 다축 프로파일 비교, 역량 평가, 다차원 지표 [SVG+JS]
- Dumbbell: 두 값 사이 갭/범위, 격차 비교 [SVG]
- Heatmap: 2D 빈도/밀도, 시간별·카테고리별 분포 [Canvas]
- Bullet: 실적 vs 목표, KPI 달성률 [SVG]
- Sparkline Grid: 다수 항목 트렌드 요약, 소형 라인차트 그리드 [SVG+JS]
- Waterfall: 증감 분해, 누적 변화, 요인별 기여도 [SVG]
Top 4를 선택하여 아래처럼 AskUserQuestion 호출:
AskUserQuestion({
questions: [{
question: "어떤 Figure 패턴이 가장 적합할까요?",
header: "패턴 선택",
multiSelect: false,
options: [
{
label: "{Pattern 1 이름}",
description: "{왜 이 패턴이 적합한지 1줄 설명}",
markdown: "{ASCII art preview}"
},
// ... 3개 더
]
}]
})ASCII Art Previews
4개 패턴을 결정한 뒤 AskUserQuestion 호출 전에 references/pattern-previews.md를 읽고 해당 패턴의 ASCII art를 가져와라.
Example: AskUserQuestion Call
User가 "사용자 인터뷰 프로세스를 시각화해줘"라고 요청한 경우:
AskUserQuestion({
questions: [{
question: "어떤 Figure 패턴이 가장 적합할까요?",
header: "패턴 선택",
multiSelect: false,
options: [
{
label: "Flow (추천)",
description: "인터뷰 단계를 수직 플로우로 표현. 프로세스 시각화에 최적",
markdown: "┌──────────────────────────────────┐\n│ ┌──────────────┐ │\n│ │ 맥락 확인 │ │\n│ └──────┬───────┘ │\n│ ▼ │\n│ ┌──────────────┐ │\n│ │ 사례 복기 │ │\n│ └──────┬───────┘ │\n│ ▼ │\n│ ┌──────────────┐ │\n│ │ 니즈 발견 │ │\n│ └──────────────┘ │\n└──────────────────────────────────┘"
},
{
label: "Journey",
description: "인터뷰이의 여정을 수평 터치포인트로 표현",
markdown: "┌──────────────────────────────────┐\n│ ①─────────②─────────③────────④ │\n│ 준비 라포 질문 정리 │\n└──────────────────────────────────┘"
},
{
label: "Timeline",
description: "인터뷰 시간 배분을 비율로 시각화",
markdown: "┌──────────────────────────────────┐\n│ ┌────────┬──────────┬────────┐ │\n│ │ 라포 │ 질문 │ 정리 │ │\n│ │ 3min │ 5min │ 2min │ │\n│ └────────┴──────────┴────────┘ │\n└──────────────────────────────────┘"
},
{
label: "Comparison",
description: "좋은 인터뷰 vs 나쁜 인터뷰를 좌우 대비",
markdown: "┌──────────────────────────────────┐\n│ ┌──────────┐ ┌──────────┐ │\n│ │ 나쁜방법 │VS│ 좋은방법 │ │\n│ │ ┌──────┐ │ │ ┌──────┐ │ │\n│ │ │평가요청│ │ │ │맥락확인│ │ │\n│ │ └──────┘ │ │ └──────┘ │ │\n│ └──────────┘ └──────────┘ │\n└──────────────────────────────────┘"
}
]
}]
})중요: markdown 필드에는 해당 컨텍스트에 맞는 실제 키워드를 넣어라. 제네릭 플레이스홀더(Step 1, Card 1)가 아닌 실제 내용을 반영한 프리뷰를 보여줘야 사용자가 판단할 수 있다.
Document Insertion
사용자가 .md 또는 .mdx 파일을 제공한 경우, PNG 저장 후 해당 파일에 이미지를 삽입한다.
삽입 규칙
1. 위치 결정: Figure가 설명하는 컨텐츠의 직후에 삽입. 해당 섹션의 마지막 문단 뒤, 다음 ## 헤딩 전 2. MDX 파일 (.mdx):
<Figure src="/blog/images/{slug}/{filename}.png" alt="설명" caption="캡션" />3. Markdown 파일 (.md):
4. 빈 줄: 삽입된 태그 앞뒤로 빈 줄 1개씩 확보 5. 복수 Figure: 같은 파일에 여러 Figure를 삽입할 경우, 각각 관련 섹션 근처에 배치
삽입 위치 판단 기준
- Figure 내용과 가장 관련 높은 헤딩(##, ###) 을 찾는다
- 해당 헤딩의 본문 마지막 문단 뒤에 삽입
- 코드 블록(```) 내부에는 절대 삽입하지 않는다
- frontmatter(
---) 내부에는 삽입하지 않는다 - 이미 동일 파일명의 Figure/image가 있으면 교체(중복 방지)
HTML Template
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1440">
<link rel="stylesheet" href="file://{SKILL_DIR}/assets/figure.css">
</head>
<body>
<!-- figure content here -->
</body>
</html>Replace {SKILL_DIR} with the absolute path to this skill's directory (the directory containing this SKILL.md). Resolve via the file path of this skill file, e.g. if SKILL.md is at /path/to/skills/blog-figure/SKILL.md, then {SKILL_DIR} = /path/to/skills/blog-figure.
Capture (pick whichever is available)
30개 패턴 전체 검수용 gallery:
python3 {SKILL_DIR}/scripts/render_pattern_previews.py --clean --output-dir /tmp/blog-figure-previews검수용 gallery는 file:///tmp/blog-figure-previews/index.html 이다. Playwright MCP/CLI는 file://를 차단할 수 있으니 필요하면 python3 -m http.server 8123 --directory /private/tmp 후 http://127.0.0.1:8123/blog-figure-previews/index.html로 연다. 상세 검수는 ?density=detail 쿼리를 붙여 2열 확대 모드로 본다. 상단 ready counter가 30 / 30 ready가 된 뒤 캡처하라.
Chrome DevTools MCP (preferred when Chrome is open): 1. mcp__chrome-devtools__emulate → viewport {width:1440, height:810, deviceScaleFactor:2} (retina 2880×1620) 2. mcp__chrome-devtools__navigate_page → file:///tmp/blog-figure-{name}.html 3. mcp__chrome-devtools__take_screenshot → filePath: {target}.png 4. After capture: mcp__chrome-devtools__emulate → viewport null (reset)
Playwright MCP: 1. mcp__playwright__browser_resize → 1440×810 2. mcp__playwright__browser_navigate → file:///tmp/blog-figure-{name}.html 3. mcp__playwright__browser_take_screenshot → filename: {target}.png
Playwright CLI:
npx playwright screenshot --viewport-size="1440,810" file:///tmp/blog-figure-{name}.html {target}.png
npx playwright screenshot --viewport-size="1600,4200" --wait-for-selector="body[data-gallery-ready='1']" --wait-for-timeout=3000 "http://127.0.0.1:8123/blog-figure-previews/index.html?density=detail" /tmp/blog-figure-previews/gallery-playwright.pngChrome CLI:
google-chrome --headless --disable-gpu --hide-scrollbars --virtual-time-budget=5000 --window-size=1600,4200 --screenshot=/tmp/blog-figure-previews/gallery-chrome.png "file:///tmp/blog-figure-previews/index.html?density=detail"캡처 실패 시 복구: 1. Chrome DevTools 연결 실패 → Playwright MCP 시도 → Fallback CLI 시도 2. 빈 PNG / 흰 화면 → HTML 파일을 Read로 확인 후 file:// 경로가 올바른지 점검. {SKILL_DIR} 경로가 실제 figure.css 위치와 일치하는지 확인 3. 폰트 깨짐 → Canvas/D3 패턴에서 document.fonts.ready.then() 래핑 누락 여부 확인 4. 모든 방법 실패 → HTML 파일 경로를 사용자에게 알려주고 수동 캡처 요청
Patterns
| Pattern | Use case | Key classes |
|---|---|---|
| Comparison | X vs Y, 좌우 대비 | .split, .vs-badge |
| Flow | 단계별 프로세스 | .flow-card, .arrow-down, .icon |
| Timeline | 시간 배분, 비율 | .timeline, .tl-block |
| Concept | 관계도, 개념 비교 | .concept-block |
| Architecture | 시스템 구성도, 레이어 | .arch, .arch-layer, .arch-node |
| Interaction | 시퀀스, 요청/응답 | .seq, .seq-msg |
| State | 상태 전이, 라이프사이클 | .state-chain, .state-node |
| Schema | DB 모델, 엔티티 관계 | .schema-table, .schema-field |
| Hierarchy | 트리, 조직도 | .tree, .tree-node, .tree-level |
| Matrix | 2x2 분석, 비교표 | .matrix, .matrix-cell |
| Journey | 사용자 여정, 터치포인트 | .journey, .journey-step |
| Funnel | 전환율, 단계별 감소 | .funnel, .funnel-stage |
| Loop | 순환 프로세스, 피드백 | .loop, .loop-node |
| Data Viz | 수치 비교, 바 차트 | .bar-chart, .bar-row |
| Storyboard | 시나리오, 단계별 장면 | .storyboard, .story-panel |
| Terminal | CLI 시각화, 터미널 UI | .terminal, .terminal-card, .terminal-option |
| Isometric | 3D 블록, 레이어, 와이어프레임 | SVG <polygon>, .iso |
| IconDiagram | 기술 다이어그램, 아이콘 연결 | SVG <rect>, <marker> |
| Network | 노드 네트워크, 추상 관계 | <canvas>, JS render |
| Graph | 포스-다이렉티드 그래프 | D3.js, <svg> |
| Waffle | 비율 체감, 퍼센트 | SVG <rect> 10x10 grid |
| Typographic Statement | 에디토리얼 인용, 핵심 정의 | SVG <text>, quote card |
| Slope | 전후 순위 변화, 랭킹 이동 | SVG <line>, <circle> |
| Treemap | 면적 비례 구성비 | D3.js treemap |
| Radar | 다축 프로파일 비교 | SVG + JS (cos/sin) |
| Dumbbell | 두 값 사이 갭/범위 | SVG <circle> x2 + <line> |
| Heatmap | 2D 빈도/밀도 분포 | <canvas>, JS lerp |
| Bullet | 실적 vs 목표, KPI | SVG nested <rect> + <line> |
| Sparkline Grid | 다수 항목 트렌드 요약 | SVG + JS, <polyline>, <polygon> |
| Waterfall | 증감 분해, 누적 변화 | SVG floating <rect> + connector |
Full HTML examples by category — 선택한 패턴이 속한 파일만 읽어라:
| Category | File | Patterns |
|---|---|---|
| Layout | references/patterns-layout.md | Comparison, Flow, Timeline, Concept, Architecture, Interaction, State, Schema, Hierarchy, Matrix, Journey, Funnel, Loop, Storyboard, Terminal (15) |
| Data Viz (static) | references/patterns-dataviz-static.md | Data Viz, Waffle, Slope, Dumbbell, Bullet, Waterfall (6) |
| Data Viz (dynamic) | references/patterns-dataviz-dynamic.md | Treemap, Radar, Heatmap, Sparkline Grid (4) |
| Visual | references/patterns-visual.md | Isometric, IconDiagram, Network, Graph, Typographic Statement (5) |
Design Rules
Full design constraints are in references/design-rules.md. Read it before writing HTML.
모바일 가독성 — 최우선 원칙
이 PNG는 모바일에서 원본의 25% 크기로 보인다.
손톱만한 크기에서도 패턴 구조와 핵심 키워드가 인식되어야 한다.
- 읽을 수 없는 텍스트는 넣지 마라 — 어차피 안 보인다
- 색상 대비와 면적으로 구조를 전달하라
- 텍스트는 "읽는 것"이 아니라 "인식하는 것"이어야 한다
- 한 Figure에 한 개의 핵심 개념만 담아라 (One Idea Per Figure)
컴포넌트 수량 제한 — 시원시원한 배치
적은 수의 큰 컴포넌트 > 많은 수의 작은 컴포넌트
| 패턴 | 최대 수량 | 이유 |
|---|---|---|
| Flow | 3단계 | 3개면 비교 충분, 5개면 텍스트 덩어리 |
| Timeline | 3블록 | 블록 크기↑, 라벨 가독성↑ |
| Storyboard | 4패널 (2×2) | 패널 크기 2배 확보 |
| Bar chart | 4행 | 바 높이 충분히 확보 |
| Architecture | 3레이어, 레이어당 3노드 (2노드면 너무 빈약) | 시스템 구조의 풍부함 |
| Split 비교 | 양쪽 각 2~3카드 | 카드 크기 유지 |
| Journey | 4단계 | dot 간 여백 확보 |
| Schema | 3테이블, 테이블당 3~4필드 | 글자 크기 유지 |
| Terminal | 3카드, 카드당 옵션 2개 (max 3) | 질문 max 12자 1줄, <br> 금지 |
| Matrix | 2×2. 코너 4자 이내 또는 빈칸 | 축 라벨이 의미 전달 |
| Waffle | 1 grid (10x10), 2 카테고리 | 총 ~8단어 (범례 포함) |
| Typographic | 1 primary text (max 8단어) | attribution max 4단어 |
| Slope | max 5항목, 2시점 | 항목명 1단어 |
| Treemap | max 6~8 cells | 라벨 1단어, 80px 미만 숨김 |
| Radar | max 5축, 1~2 series | 축 라벨 1단어 |
| Dumbbell | max 5 rows | 라벨 max 3단어 |
| Heatmap | max 7x5 grid (35 cells) | 셀 ~110x90px |
| Bullet | max 3~4 charts | 범위 3단계 + 타겟 마커 |
| Sparkline Grid | max 6 sparklines (3x2) | 항목당 라벨 1단어 + 값 1개 |
| Waterfall | max 6~8 bars | 라벨 1~2단어, 시작/합계 포함 |
/* Blog Figure — Neo-Brutalism Base CSS
Resolution: 1440×810 (16:9, blog-optimized)
Fonts: Noto Sans KR 900 (titles), Noto Sans KR 700 (body), JetBrains Mono (code/numbers)
Design for mobile: this PNG will be viewed at ~25% size on phones.
Content text minimum: 1.5rem (24px). Decorative markers: 1.25rem (20px).
Nothing below 1.25rem (20px). */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Noto+Sans+KR:wght@400;700;900&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
width: 1440px; height: 810px;
font-family: 'Noto Sans KR', sans-serif;
font-weight: 700;
color: var(--dark); background: var(--card);
overflow: hidden;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
}
/* --- Color Tokens — agentic30 design system 기반
Source: docs/design-system.md, apps/web/src/app/globals.css --- */
:root {
/* ── Base (agentic30 site tokens) ── */
--dark: #0a0a0a; /* --foreground */
--white: #ffffff; /* --background */
--accent: #FF6B35; /* --accent (primary orange) */
--highlight: #fde047; /* --highlight (text underline yellow) */
--card: #f5f5f5; /* --card */
--muted: #e5e5e5; /* --muted, --border */
--muted-fg: #737373; /* --muted-foreground */
--warm-bg: #fffbf5; /* --hero-card (warm cream) */
/* Backward-compat aliases */
--orange: var(--accent);
--yellow: var(--highlight);
--gray-100: var(--card);
/* ── Phase Colors (agentic30 curriculum) ── */
--phase-1: #ff8f50; /* Week 1 — orange */
--phase-2: #ff5c8d; /* Week 2 — pink */
--phase-3: #a3e635; /* Week 3 — lime */
--phase-4: var(--dark); /* Week 4 — black */
/* ── Semantic (derived from phases) ── */
--bad-bg: #FFD5E0; /* phase-2 tint */
--bad-card: var(--phase-2); /* #ff5c8d */
--bad-accent: #d6336c; /* dark pink for text */
--good-bg: #D9F99D; /* phase-3 tint */
--good-card: var(--phase-3); /* #a3e635 */
--good-accent: #4d7c0f; /* dark lime for text */
--info-bg: #BFDBFE; /* blue tint */
--info-card: #3B82F6; /* blue-500 */
--info-accent: #1D4ED8; /* blue-700 for text */
/* ── Palette (timeline, funnel, bars)
Site colors first, then supplements ── */
--tl-orange: var(--accent); /* #FF6B35 */
--tl-amber: var(--phase-1); /* #ff8f50 */
--tl-pink: var(--phase-2); /* #ff5c8d */
--tl-lime: var(--phase-3); /* #a3e635 */
--tl-yellow: var(--highlight);/* #fde047 */
--tl-blue: #3B82F6; /* supplement: info contrast */
--tl-purple: #8B5CF6; /* supplement: variety */
}
/* --- Typography --- */
.title { font-family: 'Noto Sans KR', sans-serif; font-weight: 900; }
.mono { font-family: 'JetBrains Mono', monospace; }
.text-sm { font-size: 1.5rem; }
.text-base { font-size: 1.5rem; }
.text-lg { font-size: 1.75rem; }
.text-xl { font-size: 2rem; }
.text-2xl { font-size: 2.25rem; }
.text-3xl { font-size: 2.75rem; }
.text-4xl { font-size: 3.25rem; }
.text-5xl { font-size: 4rem; }
.font-normal { font-weight: 400; }
.font-bold { font-weight: 700; }
.font-black { font-weight: 900; }
/* --- Core Neo-Brutalism --- */
.neo-border { border: 3px solid var(--dark); }
.neo-shadow { box-shadow: 4px 4px 0px var(--dark); }
.neo-shadow-lg { box-shadow: 6px 6px 0px var(--dark); }
.neo-shadow-xl { box-shadow: 8px 8px 0px var(--dark); }
.neo-card {
background: var(--white);
border: 3px solid var(--dark);
border-radius: 12px;
box-shadow: 4px 4px 0px var(--dark);
padding: 1.5rem;
}
/* --- Layout Helpers --- */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 0.75rem; }
.gap-2 { gap: 1.25rem; }
.gap-3 { gap: 2rem; }
.text-center { text-align: center; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.relative { position: relative; }
.absolute { position: absolute; }
/* --- Figure Title (top banner) --- */
.figure-title {
font-weight: 900;
font-size: 3rem;
background: var(--white);
border: 3px solid var(--dark);
box-shadow: 4px 4px 0px var(--dark);
padding: 0.75rem 2.5rem;
margin-bottom: 2rem;
display: inline-block;
}
/* --- Split Comparison Layout --- */
.split {
display: flex; width: 90%; height: 70%;
border: 3px solid var(--dark);
box-shadow: 6px 6px 0px var(--dark);
overflow: hidden;
}
.split-left, .split-right {
flex: 1; padding: 2.5rem;
display: flex; flex-direction: column; align-items: center;
}
.split-left { border-right: 3px solid var(--dark); }
/* --- Section Label (colored banner) --- */
.section-label {
font-weight: 900;
font-size: 2rem;
padding: 0.6rem 2rem;
color: var(--white);
display: inline-block;
margin-bottom: 1.5rem;
}
/* --- Flow Card (vertical sequence) --- */
.flow-card {
width: 420px;
background: var(--white);
border: 3px solid var(--dark);
border-radius: 12px;
padding: 1.25rem 2rem;
display: flex; align-items: center; gap: 1.25rem;
font-weight: 700;
font-size: 1.5rem;
}
.flow-card span { font-weight: 700; }
.flow-card.bad { box-shadow: 4px 4px 0px var(--bad-card); }
.flow-card.good { box-shadow: 4px 4px 0px var(--good-card); }
/* --- Icon Circle --- */
.icon {
width: 56px; height: 56px;
border-radius: 50%; border: 3px solid var(--dark);
display: flex; align-items: center; justify-content: center;
font-size: 1.6rem; flex-shrink: 0;
}
.icon.bad { background: var(--bad-card); color: var(--bad-accent); }
.icon.good { background: var(--good-card); color: var(--good-accent); }
.icon.info { background: var(--info-card); color: var(--info-accent); }
/* --- Arrows --- */
.arrow-down {
width: 3px; height: 24px;
background: var(--dark); margin: 6px 0;
position: relative;
}
.arrow-down::after {
content: '';
position: absolute; bottom: -6px; left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid var(--dark);
}
.arrow-up {
width: 3px; height: 24px;
background: var(--dark); margin: 6px 0;
position: relative;
}
.arrow-up::after {
content: '';
position: absolute; top: -6px; left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid var(--dark);
}
.arrow-right {
height: 3px; width: 40px;
background: var(--dark); margin: 0 4px;
position: relative;
}
.arrow-right::after {
content: '';
position: absolute; right: -6px; top: 50%;
transform: translateY(-50%);
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 8px solid var(--dark);
}
.arrow-left {
height: 3px; width: 40px;
background: var(--dark); margin: 0 4px;
position: relative;
}
.arrow-left::after {
content: '';
position: absolute; left: -6px; top: 50%;
transform: translateY(-50%);
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 8px solid var(--dark);
}
.arrow-label {
font-size: 1.5rem; font-weight: 700;
color: var(--info-accent);
text-align: center;
}
/* --- Dashed Arrow (horizontal) --- */
.arrow-dashed {
display: flex; align-items: center; gap: 0;
}
.arrow-dashed-line {
width: 60px; height: 0;
border-top: 4px dashed var(--orange);
}
.arrow-dashed-head {
width: 0; height: 0;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 12px solid var(--orange);
}
/* --- Timeline (horizontal blocks) --- */
.timeline {
display: flex; width: 90%;
border: 4px solid var(--dark);
box-shadow: 8px 8px 0px var(--dark);
}
.tl-block {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 2rem;
border-right: 3px solid var(--dark);
font-weight: 900;
}
.tl-block:last-child { border-right: none; }
.tl-block .tl-label { font-size: 2rem; }
.tl-block .tl-time { font-size: 1.75rem; margin-top: 0.5rem; }
/* Timeline annotation below — use sparingly, keywords only */
.tl-annotations {
display: flex; width: 90%; margin-top: 1rem;
}
.tl-anno {
display: flex; flex-direction: column;
align-items: center; gap: 0.25rem;
font-size: 1.5rem; text-align: center;
font-weight: 700;
}
/* --- Data Card (for stats) --- */
.data-card {
background: var(--bad-card);
border: 3px solid var(--dark);
padding: 1.25rem 2.5rem;
display: flex; justify-content: space-between;
align-items: center; gap: 2rem;
font-weight: 900;
font-size: 2.25rem;
}
/* --- Insight Box (footer message) --- */
.insight-box {
background: var(--yellow);
border: 3px solid var(--dark);
box-shadow: 4px 4px 0px var(--dark);
padding: 0.75rem 2rem;
font-weight: 900;
font-size: 1.75rem;
transform: rotate(-1deg);
display: inline-block;
}
/* --- Quote Card --- */
.quote-card {
background: var(--info-card);
border: 3px solid var(--dark);
border-radius: 8px;
padding: 1.25rem 2rem;
font-size: 1.75rem;
font-weight: 700;
width: 100%;
}
/* --- VS Badge --- */
.vs-badge {
position: absolute; left: 50%; top: 40px;
transform: translate(-50%, 0) rotate(5deg);
background: var(--dark); color: var(--white);
font-weight: 900;
font-size: 2.5rem;
border: 3px solid var(--white);
border-radius: 50%;
width: 90px; height: 90px;
display: flex; align-items: center; justify-content: center;
z-index: 30;
box-shadow: 4px 4px 0px var(--dark);
}
/* --- Concept Block (overlapping colored boxes) --- */
.concept-block {
border: 4px solid var(--dark);
box-shadow: 6px 6px 0px var(--dark);
padding: 2.5rem;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-weight: 900;
text-align: center;
}
/* --- Architecture (layered system diagram) --- */
.arch {
display: flex; flex-direction: column;
width: 90%;
border: 3px solid var(--dark);
box-shadow: 6px 6px 0px var(--dark);
overflow: hidden;
}
.arch-layer {
display: flex; align-items: stretch;
}
.arch-layer + .arch-layer {
border-top: 3px solid var(--dark);
}
.arch-label {
font-weight: 900;
font-size: 1.75rem;
padding: 1.25rem 1.5rem;
display: flex; align-items: center; justify-content: center;
border-right: 3px solid var(--dark);
min-width: 160px;
color: var(--white);
}
.arch-nodes {
display: flex; gap: 1.5rem; padding: 2rem;
flex: 1; justify-content: center;
align-items: center; flex-wrap: wrap;
}
.arch-node {
background: var(--white);
border: 3px solid var(--dark);
border-radius: 12px;
box-shadow: 4px 4px 0px var(--dark);
padding: 1rem 1.5rem;
font-weight: 700;
font-size: 1.5rem;
display: flex; align-items: center; gap: 0.5rem;
}
/* --- Interaction (sequence diagram) --- */
.seq {
display: flex; flex-direction: column;
width: 90%; gap: 2rem;
align-items: center;
}
.seq-entities {
display: flex; justify-content: space-between;
width: 100%;
}
.seq-entity {
font-weight: 900;
font-size: 1.75rem;
background: var(--white);
border: 3px solid var(--dark);
box-shadow: 4px 4px 0px var(--dark);
padding: 1rem 2.5rem;
text-align: center;
min-width: 180px;
}
.seq-messages {
display: flex; flex-direction: column;
gap: 1.25rem; width: 100%;
}
.seq-msg {
display: flex; align-items: center; gap: 0.5rem;
}
.seq-msg-line {
flex: 1; height: 3px;
background: var(--dark);
}
.seq-msg-line.dashed {
background: none;
border-top: 3px dashed var(--dark);
height: 0;
}
.seq-msg-label {
font-size: 1.5rem; font-weight: 700;
white-space: nowrap;
background: var(--white);
padding: 0.4rem 1rem;
border: 2px solid var(--dark);
border-radius: 6px;
}
.seq-msg-arrow {
width: 0; height: 0; flex-shrink: 0;
}
.seq-msg.right .seq-msg-arrow {
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-left: 12px solid var(--dark);
}
.seq-msg.left .seq-msg-arrow {
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
border-right: 12px solid var(--dark);
}
/* --- State (state machine) --- */
.state-chain {
display: flex; align-items: center;
gap: 0; flex-wrap: wrap;
justify-content: center;
}
.state-node {
background: var(--white);
border: 3px solid var(--dark);
border-radius: 20px;
box-shadow: 4px 4px 0px var(--dark);
padding: 1.25rem 2rem;
font-weight: 700; text-align: center;
font-size: 1.5rem;
min-width: 140px;
display: flex; flex-direction: column;
align-items: center; gap: 0.25rem;
}
.state-node.active { background: var(--yellow); }
.state-node.initial { background: var(--good-card); }
.state-node.final { background: var(--bad-card); }
.state-transition {
display: flex; flex-direction: column;
align-items: center; padding: 0 0.5rem;
}
/* --- Schema (data model) --- */
.schema-container {
display: flex; gap: 2.5rem;
justify-content: center;
align-items: flex-start;
width: 90%; flex-wrap: wrap;
}
.schema-table {
background: var(--white);
border: 3px solid var(--dark);
box-shadow: 4px 4px 0px var(--dark);
min-width: 280px;
}
.schema-header {
font-weight: 900;
font-size: 1.75rem;
padding: 1rem 1.25rem;
border-bottom: 3px solid var(--dark);
display: flex; align-items: center; gap: 0.5rem;
}
.schema-field {
padding: 0.75rem 1.25rem;
display: flex; justify-content: space-between;
align-items: center;
font-size: 1.5rem;
}
.schema-field + .schema-field {
border-top: 2px solid var(--gray-100);
}
.schema-pk {
background: var(--yellow);
border: 2px solid var(--dark);
border-radius: 4px;
padding: 0.1rem 0.6rem;
font-size: 1.25rem; font-weight: 900;
}
.schema-fk {
background: var(--info-card);
border: 2px solid var(--dark);
border-radius: 4px;
padding: 0.1rem 0.6rem;
font-size: 1.25rem; font-weight: 900;
}
/* --- Hierarchy (tree structure) --- */
.tree {
display: flex; flex-direction: column;
align-items: center;
}
.tree-node {
background: var(--white);
border: 3px solid var(--dark);
border-radius: 12px;
box-shadow: 4px 4px 0px var(--dark);
padding: 1rem 2rem;
font-weight: 700; text-align: center;
font-size: 1.5rem;
display: inline-flex; align-items: center; gap: 0.5rem;
}
.tree-level {
display: flex; justify-content: center; gap: 2.5rem;
}
.tree-branch {
display: flex; flex-direction: column;
align-items: center;
}
.tree-vline {
width: 3px; height: 28px;
background: var(--dark);
}
.tree-hline {
height: 3px; background: var(--dark);
}
/* --- Matrix (NxN grid) --- */
.matrix {
display: grid;
border: 3px solid var(--dark);
box-shadow: 6px 6px 0px var(--dark);
overflow: hidden;
}
.matrix-header {
font-weight: 900;
font-size: 1.75rem;
padding: 1.25rem;
display: flex; align-items: center; justify-content: center;
text-align: center;
background: var(--white);
}
.matrix-cell {
padding: 1.5rem;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; gap: 0.5rem;
}
.matrix-corner {
background: var(--white); color: var(--dark);
font-weight: 900;
font-size: 1.5rem;
display: flex; align-items: center; justify-content: center;
}
.matrix-label-x {
font-weight: 900;
font-size: 1.75rem;
padding: 1.25rem;
display: flex; align-items: center; justify-content: center;
text-align: center;
border-bottom: 3px solid var(--dark);
}
.matrix-label-y {
font-weight: 900;
font-size: 1.75rem;
padding: 1.25rem;
display: flex; align-items: center; justify-content: center;
text-align: center;
border-right: 3px solid var(--dark);
}
/* --- Journey (user journey map) --- */
.journey {
display: flex; align-items: flex-start;
width: 90%; position: relative;
}
.journey-line {
position: absolute;
top: 28px; left: 5%; right: 5%;
height: 3px; background: var(--dark);
z-index: 1;
}
.journey-step {
flex: 1;
display: flex; flex-direction: column;
align-items: center; text-align: center;
z-index: 2;
}
.journey-dot {
width: 56px; height: 56px;
border-radius: 50%;
border: 3px solid var(--dark);
display: flex; align-items: center; justify-content: center;
font-size: 1.75rem;
font-weight: 700;
background: var(--white);
}
.journey-label {
font-weight: 700; margin-top: 0.75rem; font-size: 1.5rem;
}
.journey-desc {
font-size: 1.5rem; font-weight: 700;
color: var(--dark); margin-top: 0.25rem;
max-width: 180px;
}
/* --- Funnel (conversion stages) --- */
.funnel {
display: flex; flex-direction: column;
align-items: center; gap: 0;
width: 90%;
}
.funnel-stage {
display: flex; align-items: center;
justify-content: space-between;
padding: 1.25rem 2.5rem;
border: 3px solid var(--dark);
font-weight: 700; font-size: 1.75rem;
}
.funnel-stage + .funnel-stage {
border-top: none;
}
.funnel-label {
font-weight: 900;
}
.funnel-value {
font-weight: 900;
}
.funnel-arrow {
width: 3px; height: 12px;
background: var(--dark);
}
/* --- Loop (cycle diagram) --- */
.loop {
display: grid;
grid-template-columns: auto auto auto;
grid-template-rows: auto auto auto;
gap: 1.5rem;
align-items: center; justify-items: center;
}
.loop-node {
background: var(--white);
border: 3px solid var(--dark);
border-radius: 12px;
box-shadow: 4px 4px 0px var(--dark);
padding: 1.25rem 2rem;
font-weight: 700; text-align: center;
font-size: 1.5rem;
display: flex; flex-direction: column;
align-items: center; gap: 0.25rem;
min-width: 180px;
}
.loop-center {
font-weight: 900;
font-size: 2.5rem;
display: flex; align-items: center; justify-content: center;
}
/* --- Data Viz (bar chart) --- */
.bar-chart {
display: flex; flex-direction: column;
gap: 1rem; width: 90%;
}
.bar-row {
display: flex; align-items: center; gap: 1.25rem;
}
.bar-label {
font-weight: 700; font-size: 1.5rem;
min-width: 160px; text-align: right;
}
.bar-track {
flex: 1; height: 56px;
background: var(--gray-100);
border: 3px solid var(--dark);
overflow: hidden;
}
.bar-fill {
height: 100%;
display: flex; align-items: center;
justify-content: flex-end;
padding-right: 1rem;
font-weight: 700;
font-weight: 900;
font-size: 1.5rem;
}
.bar-value {
font-weight: 900;
font-size: 1.5rem; min-width: 70px;
}
/* --- Storyboard (sequential panels) --- */
.storyboard {
display: grid; gap: 1.5rem; width: 90%;
}
.story-panel {
background: var(--white);
border: 3px solid var(--dark);
box-shadow: 4px 4px 0px var(--dark);
padding: 2rem;
display: flex; flex-direction: column;
align-items: center; gap: 0.75rem;
text-align: center; position: relative;
}
.story-number {
position: absolute; top: -14px; left: -14px;
background: var(--dark); color: var(--white);
font-weight: 900;
font-size: 1.5rem;
width: 44px; height: 44px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
}
.story-icon { font-size: 3rem; }
.story-caption {
font-weight: 700; font-size: 1.5rem;
}
.story-desc {
font-size: 1.5rem; font-weight: 700;
color: var(--dark);
}
/* ================================================================
UTILITIES — background, spacing, text color, badge
================================================================ */
/* --- Background Colors --- */
.bg-white { background: var(--white); }
.bg-card { background: var(--card); }
.bg-dark { background: var(--dark); color: var(--white); }
.bg-accent { background: var(--accent); }
.bg-highlight { background: var(--highlight); }
.bg-muted { background: var(--muted); }
.bg-warm { background: var(--warm-bg); }
.bg-good { background: var(--good-bg); }
.bg-bad { background: var(--bad-bg); }
.bg-info { background: var(--info-bg); }
.bg-good-card { background: var(--good-card); }
.bg-bad-card { background: var(--bad-card); }
.bg-info-card { background: var(--info-card); }
/* --- Text Colors --- */
.text-dark { color: var(--dark); }
.text-white { color: var(--white); }
.text-accent { color: var(--accent); }
.text-muted { color: var(--muted-fg); }
.text-good { color: var(--good-accent); }
.text-bad { color: var(--bad-accent); }
.text-info { color: var(--info-accent); }
/* --- Padding --- */
.p-1 { padding: 0.75rem; }
.p-2 { padding: 1.25rem; }
.p-3 { padding: 2rem; }
.p-4 { padding: 2.5rem; }
.px-1 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-2 { padding-left: 1.25rem; padding-right: 1.25rem; }
.px-3 { padding-left: 2rem; padding-right: 2rem; }
.py-1 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.py-2 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
.py-3 { padding-top: 2rem; padding-bottom: 2rem; }
/* --- Margin --- */
.m-1 { margin: 0.75rem; }
.m-2 { margin: 1.25rem; }
.m-3 { margin: 2rem; }
.mt-1 { margin-top: 0.75rem; }
.mt-2 { margin-top: 1.25rem; }
.mt-3 { margin-top: 2rem; }
.mb-1 { margin-bottom: 0.75rem; }
.mb-2 { margin-bottom: 1.25rem; }
.mb-3 { margin-bottom: 2rem; }
.ml-1 { margin-left: 0.75rem; }
.mr-1 { margin-right: 0.75rem; }
/* --- Gap (additional) --- */
.gap-4 { gap: 2.5rem; }
.gap-5 { gap: 3rem; }
/* --- Badge / Tag --- */
.badge {
display: inline-flex; align-items: center;
font-weight: 900;
font-size: 1.25rem;
padding: 0.3rem 1rem;
border: 2px solid var(--dark);
border-radius: 4px;
}
.tag {
display: inline-flex; align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 1.25rem;
padding: 0.2rem 0.75rem;
border: 2px solid var(--dark);
border-radius: 20px;
font-weight: 700;
background: var(--white);
}
/* --- Code Inline --- */
.code {
font-family: 'JetBrains Mono', monospace;
font-size: 1.25rem;
background: var(--muted);
border: 2px solid var(--dark);
border-radius: 4px;
padding: 0.15rem 0.5rem;
}
/* ================================================================
TEXTURES — CSS-only patterns (Neo-Brutalism approved)
================================================================ */
/* Dot grid — subtle polka pattern */
.bg-dots {
background-image: radial-gradient(var(--dark) 1px, transparent 1px);
background-size: 16px 16px;
}
.bg-dots-lg {
background-image: radial-gradient(var(--dark) 1.5px, transparent 1.5px);
background-size: 24px 24px;
}
/* Diagonal lines — classic hatch */
.bg-lines {
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
var(--dark) 4px,
var(--dark) 5px
);
}
.bg-lines-light {
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 6px,
var(--muted) 6px,
var(--muted) 7px
);
}
/* Grid / graph paper */
.bg-grid {
background-image:
linear-gradient(var(--muted) 1px, transparent 1px),
linear-gradient(90deg, var(--muted) 1px, transparent 1px);
background-size: 20px 20px;
}
.bg-grid-dark {
background-image:
linear-gradient(rgba(10,10,10,0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(10,10,10,0.15) 1px, transparent 1px);
background-size: 20px 20px;
}
/* Crosshatch — overlapping diagonals */
.bg-crosshatch {
background-image:
repeating-linear-gradient(
45deg,
transparent,
transparent 5px,
rgba(10,10,10,0.08) 5px,
rgba(10,10,10,0.08) 6px
),
repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
rgba(10,10,10,0.08) 5px,
rgba(10,10,10,0.08) 6px
);
}
/* ================================================================
DECORATIVE — handmade brutalism feel
================================================================ */
/* Tape — washi tape strip across top */
.tape {
position: relative;
}
.tape::before {
content: '';
position: absolute;
top: -8px; left: 50%;
transform: translateX(-50%) rotate(-2deg);
width: 120px; height: 28px;
background: var(--highlight);
border: 2px solid var(--dark);
opacity: 0.85;
z-index: 10;
}
.tape-left::before {
left: 20px; right: auto;
transform: rotate(-4deg);
}
.tape-right::before {
left: auto; right: 20px;
transform: rotate(3deg);
}
/* Sticker — circular sticker with slight rotation */
.sticker {
display: inline-flex;
align-items: center; justify-content: center;
width: 80px; height: 80px;
border-radius: 50%;
border: 3px solid var(--dark);
box-shadow: 3px 3px 0px var(--dark);
font-weight: 900;
font-size: 1.1rem;
text-align: center;
transform: rotate(-5deg);
background: var(--highlight);
}
.sticker-sm {
width: 60px; height: 60px;
font-size: 1rem;
}
.sticker-lg {
width: 100px; height: 100px;
font-size: 1.25rem;
}
/* Stamp — rectangular rubber stamp effect */
.stamp {
display: inline-block;
font-weight: 900;
font-size: 1.5rem;
padding: 0.5rem 1.75rem;
border: 4px solid currentColor;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.1em;
transform: rotate(-3deg);
opacity: 0.85;
}
.stamp-red { color: var(--bad-accent); }
.stamp-green { color: var(--good-accent); }
.stamp-blue { color: var(--info-accent); }
/* Underline highlight — marker pen effect */
.mark {
background: linear-gradient(
transparent 55%,
var(--highlight) 55%,
var(--highlight) 90%,
transparent 90%
);
padding: 0 0.2rem;
}
.mark-accent {
background: linear-gradient(
transparent 55%,
var(--accent) 55%,
var(--accent) 90%,
transparent 90%
);
padding: 0 0.2rem;
}
.mark-good {
background: linear-gradient(
transparent 55%,
var(--good-card) 55%,
var(--good-card) 90%,
transparent 90%
);
padding: 0 0.2rem;
}
.mark-bad {
background: linear-gradient(
transparent 55%,
var(--bad-card) 55%,
var(--bad-card) 90%,
transparent 90%
);
padding: 0 0.2rem;
}
/* Divider — decorative section separator */
.divider {
width: 80%;
height: 3px;
background: var(--dark);
margin: 2rem auto;
}
.divider-dashed {
width: 80%;
height: 0;
border-top: 3px dashed var(--dark);
margin: 2rem auto;
}
/* Callout — attention box */
.callout {
background: var(--warm-bg);
border: 3px solid var(--dark);
border-left: 8px solid var(--accent);
box-shadow: 4px 4px 0px var(--dark);
padding: 1.25rem 2rem;
font-size: 1.5rem;
display: flex; align-items: center; gap: 1rem;
}
.callout-icon {
font-size: 2rem; flex-shrink: 0;
}
/* --- Terminal (dark CLI mockup) --- */
.terminal {
width: 1440px; height: 810px;
padding: 0 60px;
display: flex; flex-direction: column;
justify-content: center;
background: var(--dark);
color: var(--white);
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}
.terminal-path {
color: var(--muted-fg);
font-size: 1.4rem;
font-weight: 500;
margin-bottom: 8px;
}
.terminal-prompt {
font-size: 1.5rem;
line-height: 1.6;
font-weight: 500;
margin-bottom: 20px;
}
.terminal-tool {
display: flex; align-items: center; gap: 10px;
font-size: 1.4rem;
margin-bottom: 6px;
font-weight: 500;
}
.terminal-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.terminal-text {
font-size: 1.4rem;
color: var(--muted-fg);
margin-top: 4px; margin-bottom: 22px;
font-family: 'Noto Sans KR', sans-serif;
font-weight: 500;
}
.terminal-steps {
display: flex; gap: 20px;
align-items: stretch;
}
.terminal-card {
flex: 1;
background: rgba(115, 115, 115, 0.1);
border: 2px solid rgba(115, 115, 115, 0.25);
border-radius: 12px;
padding: 18px 22px 16px;
display: flex; flex-direction: column;
}
.terminal-card-header {
display: flex; justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 1.4rem;
color: var(--muted-fg);
font-weight: 600;
}
.terminal-card-tag {
font-size: 1.4rem;
color: var(--accent);
font-weight: 700;
background: rgba(255, 107, 53, 0.12);
padding: 4px 14px;
border-radius: 5px;
}
.terminal-card-question {
font-size: 1.5rem;
font-family: 'Noto Sans KR', sans-serif;
font-weight: 700;
color: var(--white);
line-height: 1.6;
margin-bottom: 16px;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
.terminal-option {
display: flex; align-items: center; gap: 8px;
font-size: 1.4rem;
padding: 10px 16px;
border-radius: 8px;
border: 1.5px solid transparent;
font-family: 'Noto Sans KR', sans-serif;
font-weight: 500;
color: var(--muted-fg);
}
.terminal-option.selected {
background: rgba(163, 230, 53, 0.08);
border-color: rgba(163, 230, 53, 0.25);
color: var(--white);
}
.terminal-radio {
width: 20px; height: 20px;
border-radius: 50%;
border: 2.5px solid var(--muted-fg);
flex-shrink: 0;
position: relative;
}
.terminal-radio.checked { border-color: var(--good-card); }
.terminal-radio.checked::after {
content: '';
position: absolute; top: 3px; left: 3px;
width: 9px; height: 9px;
background: var(--good-card);
border-radius: 50%;
}
.terminal-divider {
border: none;
border-top: 1.5px solid rgba(115, 115, 115, 0.25);
margin: 0;
}
.terminal-answer {
display: flex; align-items: flex-start; gap: 10px;
margin-top: 16px;
font-size: 1.4rem;
color: var(--good-card);
font-family: 'Noto Sans KR', sans-serif;
font-weight: 600;
line-height: 1.5;
}
.terminal-connector {
flex-shrink: 0;
color: var(--muted-fg);
font-size: 1.6rem;
opacity: 0.35;
margin-top: 160px;
}
{
"skill_name": "blog-figure",
"evals": [
{
"id": 1,
"name": "cyclic-blog-loop-pattern",
"prompt": "이 글 읽고 figure 만들어줘.\n\n제목: \"왜 우리 팀은 스프린트마다 같은 실수를 반복할까\"\n내용 요약: 레트로스펙티브를 하면 팀이 문제를 발견하지만, 액션 아이템을 정의하지 않거나 다음 스프린트에서 확인하지 않아서 같은 문제가 다음 레트로에 다시 등장한다. 발견 → 정의 안 함 → 망각 → 재발견 의 루프가 반복된다.",
"expected_output": "Content Brief에서 순환 구조를 식별하고 Loop 패턴을 추천. 3개 이하 Loop 노드, 20단어 이내",
"assertions": [
{ "text": "Brief presents 3 genuinely distinct scenes, not paraphrases", "type": "qualitative" },
{ "text": "Brief structure identifies 순환/반복 루프", "type": "qualitative" },
{ "text": "Loop pattern appears in top 2 recommendations", "type": "check" },
{ "text": "ASCII preview uses actual keywords (발견, 망각, 재발견), not generic placeholders", "type": "check" },
{ "text": "Final figure has at most 3 Loop nodes", "type": "count" },
{ "text": "Total text across figure ≤ 20 words", "type": "count" }
],
"files": [],
"focus": "Pattern mismatch — Loop vs Flow confusion"
},
{
"id": 2,
"name": "ambiguous-data-slope-vs-bar",
"prompt": "스택 오버플로우 개발자 설문 2024 결과를 figure로 만들어줘.\n가장 많이 쓰는 언어 랭킹이 2020년에는 Python, JavaScript, Java 순서였는데 2024년에는 JavaScript, Python, TypeScript, Rust 순서로 바뀌었어. TypeScript랑 Rust의 급부상이 포인트야.",
"expected_output": "Brief에서 순위 변화 vs 스냅샷 다의성을 표면화. 순위 변화 해석 시 Slope 패턴 추천",
"assertions": [
{ "text": "Brief correctly identifies two distinct structures (ranking change vs snapshot)", "type": "qualitative" },
{ "text": "If ranking-change interpretation selected, Slope appears in top 2 patterns", "type": "check" },
{ "text": "Slope ASCII preview shows 2020 and 2024 columns with actual language names", "type": "check" },
{ "text": "Slope figure has ≤ 5 items", "type": "count" },
{ "text": "Language names are one word each (Python, not Python 언어)", "type": "check" },
{ "text": "SVG font-size labels ≥ 20px", "type": "check" }
],
"files": [],
"focus": "Pattern ambiguity not surfaced in brief; Slope omitted"
},
{
"id": 3,
"name": "vague-prompt-brief-gate",
"prompt": "마이크로서비스 아키텍처 figure 만들어줘.",
"expected_output": "Content Brief를 먼저 호출하여 3가지 해석 제시. Brief 확인 전 패턴 선택으로 건너뛰지 않음",
"assertions": [
{ "text": "Content Brief AskUserQuestion is called BEFORE pattern selection", "type": "check" },
{ "text": "Three brief options represent meaningfully different architectural angles", "type": "qualitative" },
{ "text": "Pattern selection only offered after user responds to Brief", "type": "check" },
{ "text": "If Architecture selected, ≥ 3 nodes per layer, ≥ 3 layers", "type": "count" },
{ "text": "No decorative emojis on arch nodes unless semantically necessary", "type": "check" }
],
"files": [],
"focus": "Content Brief step skipped entirely on vague prompts"
},
{
"id": 4,
"name": "typographic-statement-minimal",
"prompt": "글에서 이 문장을 figure로 만들어줘:\n\n\"우리가 측정하는 것이 우리가 만드는 것을 결정한다.\"\n\n짧고 강한 선언문 형태가 좋아. 다른 설명 없이 이 문장 하나만 크게.",
"expected_output": "Typographic Statement 패턴 선택. SVG 중앙 정렬. 불필요한 .figure-title, .insight-box 없음",
"assertions": [
{ "text": "Typographic Statement is selected as pattern", "type": "check" },
{ "text": "SVG viewBox is 0 0 1440 810", "type": "check" },
{ "text": "Primary text is vertically and horizontally centered in SVG", "type": "check" },
{ "text": "No .figure-title box added separately", "type": "check" },
{ "text": "No .insight-box added", "type": "check" },
{ "text": "Font-size for primary text ≥ 48px", "type": "check" },
{ "text": "No gradient, no blur shadow in output HTML", "type": "check" }
],
"files": [],
"focus": "Over-engineering: extra boxes/titles added; centering wrong"
},
{
"id": 5,
"name": "funnel-with-mdx-insertion",
"prompt": "이 mdx 파일 읽고 \"퍼널 전환율\" 섹션에 들어갈 figure 만들어줘.\n\n섹션 내용:\n- 랜딩 방문: 12,000명\n- 회원가입: 1,800명 (15%)\n- 첫 결제: 360명 (20%)\n- 재구매: 108명 (30%)\n- 단계별 이탈이 얼마나 극적인지를 보여주고 싶어",
"expected_output": "Funnel 패턴 추천. 4단계 정확히 반영. 문서 삽입 시 올바른 위치에 <Figure> 태그 삽입",
"assertions": [
{ "text": "Brief correctly identifies 순서/단계별 감소 structure", "type": "qualitative" },
{ "text": "Funnel appears in top 2 pattern recommendations", "type": "check" },
{ "text": "Funnel has exactly 4 stages", "type": "count" },
{ "text": "Stage labels are 1-2 words max (방문, 가입, 결제, 재구매)", "type": "check" },
{ "text": "Actual numbers present: 12000, 1800, 360, 108", "type": "check" },
{ "text": "Total figure text ≤ 20 words", "type": "count" },
{ "text": "Font-size on stage labels ≥ 24px (content tier)", "type": "check" }
],
"files": [],
"focus": "End-to-end workflow with document insertion"
}
],
"cross_cutting_assertions": [
"Total word count ≤ 20 words (≤ 35 for Terminal)",
"No CSS font-size below 1.25rem (20px); no SVG font-size below 20; no Canvas below 20px×scale",
"No hardcoded hex colors in HTML (only var(--*) CSS variables)",
"No blur shadows — only flat Npx Npx 0px box shadows",
"No gradients",
"Component counts respect per-pattern maximums",
"ASCII art preview customized with actual content keywords, not Step 1 / Card 1",
"Three Content Brief options represent different scenes, not paraphrases"
]
}
Design Rules Reference
HTML 구현 시 반드시 참고할 디자인 제약 모음. SKILL.md Workflow 4단계에서 읽어라.
---
Core Rules
- Size: 1440×810 (16:9)
- Border: 3px solid #0a0a0a
- Shadow: Npx Npx 0px #0a0a0a (no blur ever)
- Title font: Noto Sans KR 900 (headings/labels only)
- Body font: Noto Sans KR, weight 700 (bold default). Use 400 only for minor annotations
- Code/number font: JetBrains Mono (
.mono,.code,.tag) - Colors: CSS variables only — never hardcode hex in HTML (Canvas/D3 패턴은 JS 내 hex 허용)
- No: gradients, blur shadows, soft edges
시각 패턴 (Isometric, IconDiagram, Network, Graph) 참고
- SVG 패턴 (Isometric, IconDiagram):
<svg>인라인. 정밀한 좌표 배치. figure.css 색상 변수 사용 가능. - Canvas 패턴 (Network):
<canvas>+ JS.document.fonts.ready.then()으로 폰트 로드 후 렌더링. retina 2x (width=2880, height=1620, style width=1440px). - D3 패턴 (Graph):
<script src="https://d3js.org/d3.v7.min.js">. 시뮬레이션 동기 실행:for(let i=0;i<300;i++) sim.tick(); sim.stop(); - 공통: dot grid 배경 (
<pattern>SVG 또는 Canvas 루프), 모노스페이스 섹션 라벨, 텍스트 최소화 — 도형과 연결선으로 구조 전달
SVG/Canvas/D3 인라인 font-size 규칙
| 역할 | 최소 px | 비고 |
|---|---|---|
| 콘텐츠 라벨 | 24px | font-size="24", ctx.font = '${24*S}px ...' |
| 보조 라벨 (축 눈금, 순위 등) | 20px | 18px 이하 금지 |
텍스트 버짓 — 절대 한도
Figure는 글의 설명을 대체하지 않는다. 시각적 앵커를 제공할 뿐이다.
| 컴포넌트 | 최대 텍스트 | 예시 |
|---|---|---|
.flow-card | 제목 2~4단어. 설명 생략 또는 max 6자 | "맥락 확인", "사례 복기" |
.quote-card | max 8자 (핵심 키워드만) | "좋은데요!", "0건" |
.section-label | max 6자 | "나쁜 예시", "좋은 방법" |
.tl-anno | 사용 자제. 쓸 경우 max 4자 키워드 | "맥락", "사례" |
.journey-desc | max 6자 | "서비스 인지" |
.story-desc | max 6자 | "결제 확인" |
.bar-label | max 4단어 | "React", "설문 응답률" |
.terminal-card-question | max 12자, 1줄 권장 (<br> 금지, balance 자동 줄바꿈) | "성공 기준은?" |
.terminal-option | max 8자 | "WAU 활성만" |
.terminal-answer | max 8자 | "첫 매출" |
.schema-field | 필드명 max 8자. 타입 주석 생략 | "user_id FK" |
.matrix-corner | max 4자 또는 빈칸 | "" |
| Figure 전체 | max 20단어 (Terminal 예외: 35단어) | — |
최소 폰트 크기 — 2-tier 시스템
1440px 캔버스에서 1.25rem(20px) 미만 절대 금지. 콘텐츠 텍스트(읽어야 하는 키워드)는 1.5rem(24px) 이상.
| Tier | 최소 크기 | 25% 환산 | 해당 요소 |
|---|---|---|---|
| 콘텐츠 | 1.5rem (24px) | 6px | .flow-card, .bar-label, .state-node, .arch-node, .schema-field, .arrow-label, .journey-desc, .story-desc, .tl-anno, .matrix-corner, .terminal-prompt, .terminal-card-question |
| 장식 마커 | 1.25rem (20px) | 5px | .tag, .badge, .schema-pk, .schema-fk |
| 섹션 헤딩 | 2rem (32px) | 8px | .section-label |
| Figure 제목 | 3rem (48px) | 12px | .figure-title |
장식 전용 클래스 (.sticker, .sticker-sm, .code)는 콘텐츠 용도 사용 금지. Terminal 보조 텍스트 (.terminal-option, .terminal-answer 등)는 1.4rem(22px) 허용. SVG/Canvas 인라인: font-size 최소 20px. 18px 이하 금지.
Weight Hierarchy
| Role | Weight | Example |
|---|---|---|
| 모든 텍스트 기본값 | 700 | body default, .flow-card, .journey-desc |
| 부연/보조 (드물게 사용) | 400 | 긴 설명이 불가피할 때만 |
| Display titles | Noto Sans KR 900 | .figure-title, .section-label |
Composition — 정형화 피하기
.figure-title+.insight-box를 매번 넣지 마라. 컨텐츠만으로 의미가 전달되면 생략- 제목이 필요하면
.figure-title대신 패턴 내부에 자연스럽게 포함 - 하단 요약이 필요하면
.insight-box대신.callout이나.mark로 변주 - 같은 블로그 포스트 내 여러 Figure는 각각 다른 구성을 사용할 것
아이콘/이모지 원칙
- 텍스트만으로 충분하면 아이콘 생략.
.icon원형은 선택 사항 .flow-card는 아이콘 없이<strong>제목</strong>만으로 충분. 설명 텍스트 최소화- 이모지는 카테고리 구분이 반드시 필요할 때만 (예:
.arch-label,.journey-dot) - 장식용 이모지/아이콘 금지
패턴별 레이아웃 규칙
모바일 축소 상태에서도 구조가 한눈에 잡히려면, 각 패턴의 정렬과 색상 사용이 일관되어야 한다.
Comparison (Split)
- 양쪽 카드는 동일한 상단 시작점에서 시작.
margin-top등으로 한쪽만 아래로 밀지 마라 .split-left와.split-right에justify-content: flex-start를 명시하고, 카드 컨테이너의 구조를 동일하게 유지
Matrix (2×2)
- 열별 색상 통일: 1열은 하나의 색상 계열, 2열은 다른 색상 계열. 같은 열의 셀은 모두 동일 색상
- 헤더 ≠ 셀: 헤더(
.matrix-label-x)는 진한 톤(--good-card,--info-card), 본문 셀은 연한 톤(--good-bg,--info-bg) - 셀 구분선:
.matrix에background:var(--dark);gap:3px적용 (grid gap 기법). 개별 셀에 border 넣지 마라 — 이중 테두리가 생긴다 - 예: 1열 헤더
--good-card→ 1열 셀--good-bg, 2열 헤더--info-card→ 2열 셀--info-bg - 코너(좌상단)는 흰색:
background:var(--white). 검은색 금지
Architecture
- 레이어당 노드 최소 3개. 2개만 넣으면 레이어 영역 대비 노드가 빈약해 보인다
- 프롬프트에 2개만 명시되어 있어도, 맥락상 추가 가능한 노드를 보충하라 (예: Client 레이어에 Web, Mobile 외 CLI 추가)
Waffle (SVG)
- 제목은 한국어로, 그리드 상단 또는 좌측에 배치. 영문 부제목/서브타이틀 금지
- 범례(legend)는 그리드 우측에 컴팩트하게. 범례 텍스트도 한국어
Typographic Statement (SVG)
- 인용문은 캔버스(1440×810) 기준 수직·수평 모두 중앙 정렬
- SVG의 viewBox를
0 0 1440 810으로 설정하고, 텍스트 블록의 y 좌표를 중앙에 맞춰라
Textures & Decorations
.bg-dots,.bg-lines,.bg-grid,.bg-crosshatch— 시각적 깊이.tape,.sticker,.stamp,.mark— 수제 느낌.bg-*,.p-*,.m-*,.badge,.tag— inline style 최소화
File Naming
apps/content/src/content/blog/images/{blog-slug}/{blog-slug}-{figure-name}.pngMDX Usage
<Figure src="/blog/images/{slug}/{filename}.png" alt="..." caption="..." />Pattern ASCII Art Previews
AskUserQuestion의 markdown 필드에 사용할 ASCII art 프리뷰. 4개 패턴을 결정한 뒤 이 파일에서 해당 패턴명으로 찾아 복사하라.
프리뷰는 구조 템플릿이다. 복사한 뒤 반드시 현재 컨텍스트에 맞게 바꿔라.
Left,Right,Step 1,Scene 1같은 플레이스홀더는 실제 키워드로 교체- 숫자/시간은 블로그 본문에 있는 실제 수치로 교체
- 길어진 문장은 줄이지 말고 더 짧은 명사구로 다시 쓴다
- 원본 ASCII의 배치 구조는 유지하고, 텍스트만 맥락에 맞게 바꾼다
- 최종 목표는 "도식 구조를 보여주는 미니 와이어프레임"이지, 본문 요약이 아니다
---
Comparison
┌──────────────────────────────────┐
│ ┌──────────┐ ┌──────────┐ │
│ │ Left │VS│ Right │ │
│ │ ┌──────┐ │ │ ┌──────┐ │ │
│ │ │Card 1│ │ │ │Card 1│ │ │
│ │ └──────┘ │ │ └──────┘ │ │
│ │ ┌──────┐ │ │ ┌──────┐ │ │
│ │ │Card 2│ │ │ │Card 2│ │ │
│ │ └──────┘ │ │ └──────┘ │ │
│ └──────────┘ └──────────┘ │
└──────────────────────────────────┘Flow
┌──────────────────────────────────┐
│ ┌──────────────┐ │
│ │ Step 1 │ │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Step 2 │ │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Step 3 │ │
│ └──────────────┘ │
└──────────────────────────────────┘Timeline
┌──────────────────────────────────┐
│ ┌────────┬──────────┬────────┐ │
│ │ Block1 │ Block2 │ Block3 │ │
│ │ 3min │ 5min │ 4min │ │
│ └────────┴──────────┴────────┘ │
└──────────────────────────────────┘Concept
┌──────────────────────────────────┐
│ ┌───────┐ │
│ │ A │ ┌───────────┐ │
│ │ ├──┤ B │ │
│ └───────┘ │ ├──┐ │
│ └───────────┘ │ │
│ ┌─────────┤ │
│ │ C │ │
│ └─────────┘ │
└──────────────────────────────────┘Architecture
┌──────────────────────────────────┐
│ Client │ [Web] [Mobile] [CLI] │
│─────────┼────────────────────────│
│ Service │ [API] [Auth] [Events] │
│─────────┼────────────────────────│
│ Data │ [PostgreSQL] [Redis] │
└──────────────────────────────────┘Interaction
┌──────────────────────────────────┐
│ [User] [Server] │
│ │── Request ──────────▶│ │
│ │◀── Response ─ ─ ─ ──│ │
│ │── Action ───────────▶│ │
│ │◀── Result ─ ─ ─ ─ ──│ │
└──────────────────────────────────┘State
┌──────────────────────────────────┐
│ (Start)──▶[State A]──▶[State B] │
│ │ │
│ ▼ │
│ [State C] │
│ │ │
│ ▼ │
│ (End) │
└──────────────────────────────────┘Schema
┌──────────────────────────────────┐
│ ┌──────────┐ ┌──────────┐ │
│ │ User │ │ Post │ │
│ ├──────────┤ ├──────────┤ │
│ │ id PK │ │ id PK │ │
│ │ email │──│ user FK │ │
│ │ name │ │ title │ │
│ └──────────┘ └──────────┘ │
└──────────────────────────────────┘Hierarchy
┌──────────────────────────────────┐
│ [Root] │
│ ┌──┼──┐ │
│ ▼ ▼ ▼ │
│ [A] [B] [C] │
│ ┌┼┐ │
│ ▼ ▼ │
│ [D][E] │
└──────────────────────────────────┘Matrix
┌──────────────────────────────────┐
│ │ High │ Low │
│─────────┼──────────┼────────────│
│ Easy │ QuickWin │ Fill │
│─────────┼──────────┼────────────│
│ Hard │ Big Bet │ Avoid │
└──────────────────────────────────┘Journey
┌──────────────────────────────────┐
│ ①─────────②─────────③────────④ │
│ 발견 가입 Aha! 재방문 │
└──────────────────────────────────┘Funnel
┌──────────────────────────────────┐
│ ┌────────────────────────────┐ │
│ │ Visit 10,000 │ │
│ ├──────────────────────┤ │
│ │ Signup 3,200 │ │
│ ├────────────────┤ │
│ │ Trial 720 │ │
│ ├──────────┤ │
│ │ Pay 180 │ │
└──────────────────────────────────┘Loop
┌──────────────────────────────────┐
│ [Plan]───▶[Do] │
│ ▲ │ │
│ │ ↻ ▼ │
│ [Act]◀───[Check] │
└──────────────────────────────────┘Data Viz
┌──────────────────────────────────┐
│ React ████████████████░░ 85% │
│ Vue ███████████████░░░ 78% │
│ Svelte ██████████████████ 92% │
│ Angular████████████░░░░░░ 54% │
└──────────────────────────────────┘Storyboard
┌──────────────────────────────────┐
│ ┌─────────┐ ┌─────────┐ │
│ │① │ │② │ │
│ │ Scene 1 │ │ Scene 2 │ │
│ └─────────┘ └─────────┘ │
│ ┌─────────┐ ┌─────────┐ │
│ │③ │ │④ │ │
│ │ Scene 3 │ │ Scene 4 │ │
│ └─────────┘ └─────────┘ │
└──────────────────────────────────┘Terminal
┌──────────────────────────────────┐
│ ~ /project │
│ > command │
│ ● Tool output │
│ ┌─Step 1─┐ ┌─Step 2─┐ ┌─Step 3─┐│
│ │ ● opt │ │ ○ opt │ │ ● opt ││
│ │ ○ opt │ │ ● opt │ │ ○ opt ││
│ └────────┘ └────────┘ └────────┘│
│ ● Result │
└──────────────────────────────────┘Isometric
┌──────────────────────────────────┐
│ ◇◇◇ │
│ ╱ UI╲ │
│ ◇◇◇◇◇◇◇ │
│ ╱Service╲ │
│ ◇◇◇◇◇◇◇◇◇◇◇ │
│ ╱ Infra ╲ │
└──────────────────────────────────┘IconDiagram
┌──────────────────────────────────┐
│ ┌──────┐ ┌──────┐ │
│ │ ◉ │───────▶ │ ⚙ │ │
│ │ User │ │ API │ │
│ └──────┘ └──┬───┘ │
│ ▼ │
│ ┌──────┐ │
│ │ ⊡ DB │ │
│ └──────┘ │
└──────────────────────────────────┘Network
┌──────────────────────────────────┐
│ [GRID] [SCATTER] │
│ ○ ○ ○ ○ ◯ ○ │
│ ○ ○ ○ ○ ○──◯──○ │
│ ╲ ╱ ╲ │
│ ◯ ○ │
└──────────────────────────────────┘Graph
┌──────────────────────────────────┐
│ ◯───────◯ │
│ ╱ ╲ ╱ │
│ ◯ ◯──◯ │
│ ╲ ╱ │
│ ◯ │
└──────────────────────────────────┘Waffle
┌──────────────────────────────────┐
│ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │
│ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ 72% │
│ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ 채택 │
│ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │
│ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │
│ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │
│ ■ ■ ■ ■ ■ ■ ■ ■ □ □ │
│ □ □ □ □ □ □ □ □ □ □ 28% │
│ □ □ □ □ □ □ □ □ □ □ 미채택 │
│ □ □ □ □ □ □ □ □ □ □ │
└──────────────────────────────────┘Typographic Statement
┌──────────────────────────────────┐
│ │
│ ┌─────────────────────────┐ │
│ │ 사용자가 말하는 것과 │ │
│ │ 실제 행동은 다르다 │ │
│ └─────────────────────────┘ │
│ │
│ — Jakob Nielsen │
└──────────────────────────────────┘Slope
┌──────────────────────────────────┐
│ 2023 2024 │
│ 1. React ───────── 3. React │
│ 2. Svelte ────╲╱── 1. Svelte │
│ 3. Vue ────╱╲── 2. Vue │
│ 4. Angular───────── 5. Angular│
│ 5. Solid ───────── 4. Solid │
└──────────────────────────────────┘Treemap
┌──────────────────────────────────┐
│ ┌──────────────┬────────────┐ │
│ │ │ │ │
│ │ A 40% │ B 25% │ │
│ │ │ │ │
│ ├────────┬─────┴────────────┘ │
│ │ C │ D │ │
│ │ 20% │ 15% │ │
│ └────────┴─────┘ │
└──────────────────────────────────┘Radar
┌──────────────────────────────────┐
│ 속도 │
│ ╱ ╲ │
│ DX ╱ ████ ╲ 생태계 │
│ ╱ ████ ╲ │
│ 학습 ████████ 유연성 │
│ ╲ ╱ │
│ 안정성 │
└──────────────────────────────────┘Dumbbell
┌──────────────────────────────────┐
│ 2023 2024 │
│ React ●──────────────────● │
│ Vue ●────────────● │
│ Svelte ●─────────────● │
│ Angular ●────● │
└──────────────────────────────────┘Heatmap
┌──────────────────────────────────┐
│ 월 화 수 목 금 토 일 │
│ 9am ░░ ▓▓ ▓▓ ▓▓ ░░ ░░ ░░│
│ 12 ▓▓ ██ ██ ▓▓ ▓▓ ░░ ░░│
│ 3pm ░░ ▓▓ ██ ██ ▓▓ ░░ ░░│
│ 6pm ░░ ░░ ▓▓ ▓▓ ░░ ░░ ░░│
│ ░ 낮음 ██ 높음 │
└──────────────────────────────────┘Bullet
┌──────────────────────────────────┐
│ 매출 ░░░░▓▓▓▓████│████ │
│ ▲ target │
│ 성장률 ░░░▓▓▓████████│ │
│ NPS ░░░▓▓▓▓███│ │
└──────────────────────────────────┘Sparkline Grid
┌──────────────────────────────────┐
│ ┌──────────┐ ┌──────────┐ ┌────┐│
│ │React 85 │ │Vue 62 │ │Svel││
│ │ ╱‾‾╲__╱ │ │ __╱‾ │ │ __╱││
│ │╱ ‾│ │╱‾ │ │╱ ││
│ └──────────┘ └──────────┘ └────┘│
│ ┌──────────┐ ┌──────────┐ ┌────┐│
│ │Angular 42│ │Solid 35 │ │Qwik││
│ │‾╲__ │ │ __╱ │ │ __││
│ │ ‾╲__ │ │ __╱ │ │_╱ ││
│ └──────────┘ └──────────┘ └────┘│
└──────────────────────────────────┘Waterfall
┌──────────────────────────────────┐
│ ┌──┐ │
│ ┌──┐ │+25│ │
│ │+40│ └──┼──┐ │
│ ┌──┤ └──┘ │-30│ │
│ │ └──┘ └──┼──┐ │
│ │100│ │-20│ ┌──┐ │
│ │ │ └──┘ │115│ │
│ └───┘ └───┘ │
│ 시작 매출 서비스 원가 운영비 합계│
└──────────────────────────────────┘Figure Patterns — Data Visualization (Dynamic)
JS/D3/Canvas patterns: Treemap, Radar, Heatmap, Sparkline Grid.
All use assets/figure.css. 핵심 원칙:
- 텍스트는 최소화하되 직접 라벨을 우선한다.
- small multiple 비교는 shared scale을 기본값으로 둔다.
- 범례는 꼭 필요할 때만 쓰고, 가능하면 차트 안에서 설명한다.
- Radar는 동일 스케일 축 5개 이하 / 시리즈 2개 이하일 때만 사용한다.
- 모바일 25% 축소에서도 구조가 남도록, 색보다 정렬·여백·두께로 위계를 만든다.
TOC
- 24. Treemap
- 25. Radar
- 27. Heatmap
- 29. Sparkline Grid
---
24. Treemap (D3)
Best for: 면적 비례 구성비, 카테고리별 비중, 2D 면적으로 비율 비교
Approach: D3 v7 CDN — d3.treemap().tile(d3.treemapSquarify). flat data {children: [{name, value, color}]}.
Max: 6~8 leaf nodes, 라벨 1단어. 셀 폭 80px 미만이면 라벨 숨김.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1440">
<link rel="stylesheet" href="file://{SKILL_DIR}/assets/figure.css">
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body { width:1440px;height:810px;margin:0;overflow:hidden;background:#f5f5f5; }
.cell { stroke: #0a0a0a; stroke-width: 3; }
.cell-label { font-family: 'Noto Sans KR', sans-serif; font-weight: 900; font-size: 28px;
fill: #0a0a0a; dominant-baseline: hanging; }
.cell-value { font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 22px;
fill: rgba(10,10,10,0.72); dominant-baseline: hanging; }
</style>
</head>
<body>
<svg width="1440" height="810">
<defs>
<pattern id="dots" width="16" height="16" patternUnits="userSpaceOnUse">
<circle cx="8" cy="8" r="1" fill="rgba(10,10,10,0.06)"/>
</pattern>
</defs>
<rect width="1440" height="810" fill="url(#dots)"/>
</svg>
<script>
// === CONFIG: Modify for your content ===
const data = {
children: [
{ name: 'React', value: 40, color: '#3B82F6' },
{ name: 'Vue', value: 25, color: '#a3e635' },
{ name: 'Svelte',value: 18, color: '#FF6B35' },
{ name: 'Angular',value:10, color: '#ff5c8d' },
{ name: 'Solid', value: 5, color: '#8B5CF6' },
{ name: 'Qwik', value: 2, color: '#fde047' },
]
};
const svg = d3.select('svg');
const margin = { top: 80, right: 80, bottom: 80, left: 80 };
const W = 1440 - margin.left - margin.right;
const H = 810 - margin.top - margin.bottom;
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
data.children.sort((a, b) => d3.descending(a.value, b.value));
const root = d3.hierarchy(data).sum(d => d.value);
d3.treemap().size([W, H]).tile(d3.treemapSquarify).paddingInner(8).paddingOuter(0)(root);
// Draw cell shadows (hard shadow)
g.selectAll('.shadow').data(root.leaves()).join('rect')
.attr('x', d => d.x0 + 4).attr('y', d => d.y0 + 4)
.attr('width', d => d.x1 - d.x0).attr('height', d => d.y1 - d.y0)
.attr('fill', '#0a0a0a').attr('opacity', 0.08);
const cells = g.selectAll('.cell-group').data(root.leaves()).join('g')
.attr('class', 'cell-group')
.attr('transform', d => `translate(${d.x0},${d.y0})`);
// Draw cells
cells.append('rect')
.attr('class', 'cell')
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.attr('fill', d => d.data.color);
// Draw labels in top-left instead of centered
cells.append('text')
.attr('class', 'cell-label')
.attr('x', 18)
.attr('y', 18)
.text(d => ((d.x1 - d.x0) >= 130 && (d.y1 - d.y0) >= 76) ? d.data.name : '');
cells.append('text')
.attr('class', 'cell-value')
.attr('x', 18)
.attr('y', 56)
.text(d => ((d.x1 - d.x0) >= 150 && (d.y1 - d.y0) >= 110) ? d.data.value + '%' : '');
</script>
</body>Notes: D3 treemap은 d3.treemapSquarify 타일링으로 정사각형에 가까운 셀을 생성. data.children 배열만 수정하면 내용 변경 가능. Hard shadow는 offset된 검정 rect (opacity 0.08). 중앙 정렬보다 좌상단 직접 라벨이 면적 비교에 더 자연스럽고, 작은 셀은 과감히 숨긴다. 면적 비교는 정밀 수치보다 구성을 읽는 용도이므로 라벨 threshold를 보수적으로 잡는 편이 낫다.
---
25. Radar (SVG + JS)
Best for: 다축 프로파일 비교, 역량 평가, 3~5개 축 지표 비교
Approach: SVG + inline `<script>` — cos/sin 좌표 계산. CDN 불필요. 12시 시작 (-PI/2 offset).
Max: 5축, 1~2 data series, 축 라벨 1단어
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1440">
<link rel="stylesheet" href="file://{SKILL_DIR}/assets/figure.css">
</head>
<body>
<svg id="radar" viewBox="0 0 1440 810" xmlns="http://www.w3.org/2000/svg"
style="width:1440px;height:810px;background:#f5f5f5">
<defs>
<style>
text { font-family: 'Noto Sans KR', sans-serif; fill: #0a0a0a; }
.guide { fill: none; stroke: #e5e5e5; stroke-width: 1.5; }
.axis { stroke: #d4d4d4; stroke-width: 1.5; }
.data-poly { stroke-width: 3; }
.data-dot { stroke: #0a0a0a; stroke-width: 2.5; }
.benchmark { fill: none; stroke-width: 3; stroke-dasharray: 10 8; }
</style>
<pattern id="dots" width="16" height="16" patternUnits="userSpaceOnUse">
<circle cx="8" cy="8" r="1" fill="rgba(10,10,10,0.06)"/>
</pattern>
</defs>
<rect width="1440" height="810" fill="url(#dots)"/>
</svg>
<script>
// === CONFIG ===
const axes = ['속도', 'DX', '생태계', '유연성', '안정성'];
const focus = { name: 'React', values: [0.7, 0.8, 0.95, 0.85, 0.9], color: '#3B82F6' };
const benchmark = { name: 'Benchmark', values: [0.65, 0.72, 0.75, 0.7, 0.78], color: '#737373' };
const svg = document.getElementById('radar');
const ns = 'http://www.w3.org/2000/svg';
const cx = 720, cy = 405, R = 280;
const n = axes.length;
function pt(i, r) {
const angle = (2 * Math.PI * i / n) - Math.PI / 2;
return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
}
function polyPoints(values, radius) {
return values.map((v, i) => pt(i, v * radius).join(',')).join(' ');
}
// Guide polygons: 25%, 50%, 75%, 100%
[0.25, 0.5, 0.75, 1.0].forEach(pct => {
const poly = document.createElementNS(ns, 'polygon');
poly.setAttribute('class', 'guide');
poly.setAttribute('points', polyPoints(Array(n).fill(pct), R));
svg.appendChild(poly);
const ringLabel = document.createElementNS(ns, 'text');
ringLabel.setAttribute('x', cx + 12);
ringLabel.setAttribute('y', cy - pct * R + 6);
ringLabel.setAttribute('font-size', '18');
ringLabel.setAttribute('font-weight', '700');
ringLabel.setAttribute('fill', '#737373');
ringLabel.textContent = Math.round(pct * 100);
svg.appendChild(ringLabel);
});
// Axis lines
for (let i = 0; i < n; i++) {
const [x, y] = pt(i, R);
const line = document.createElementNS(ns, 'line');
line.setAttribute('class', 'axis');
line.setAttribute('x1', cx); line.setAttribute('y1', cy);
line.setAttribute('x2', x); line.setAttribute('y2', y);
svg.appendChild(line);
}
// Axis labels
axes.forEach((label, i) => {
const [x, y] = pt(i, R + 40);
const txt = document.createElementNS(ns, 'text');
txt.setAttribute('x', x); txt.setAttribute('y', y);
txt.setAttribute('text-anchor', 'middle');
txt.setAttribute('dominant-baseline', 'central');
txt.setAttribute('font-size', '24'); txt.setAttribute('font-weight', '900');
txt.textContent = label;
svg.appendChild(txt);
});
// Focus polygon (filled)
const focusPoly = document.createElementNS(ns, 'polygon');
focusPoly.setAttribute('class', 'data-poly');
focusPoly.setAttribute('points', polyPoints(focus.values, R));
focusPoly.setAttribute('fill', 'rgba(59,130,246,0.2)');
focusPoly.setAttribute('stroke', focus.color);
svg.appendChild(focusPoly);
// Benchmark polygon (outline only)
const benchPoly = document.createElementNS(ns, 'polygon');
benchPoly.setAttribute('class', 'benchmark');
benchPoly.setAttribute('points', polyPoints(benchmark.values, R));
benchPoly.setAttribute('stroke', benchmark.color);
svg.appendChild(benchPoly);
focus.values.forEach((v, i) => {
const [x, y] = pt(i, v * R);
const dot = document.createElementNS(ns, 'circle');
dot.setAttribute('class', 'data-dot');
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
dot.setAttribute('r', '8'); dot.setAttribute('fill', focus.color);
svg.appendChild(dot);
});
// Legend (bottom right)
[
{ name: focus.name, color: focus.color, dashed: false },
{ name: benchmark.name, color: benchmark.color, dashed: true },
].forEach((s, i) => {
const lx = 1080, ly = 650 + i * 42;
const line = document.createElementNS(ns, 'line');
line.setAttribute('x1', lx); line.setAttribute('y1', ly);
line.setAttribute('x2', lx + 24); line.setAttribute('y2', ly);
line.setAttribute('stroke', s.color);
line.setAttribute('stroke-width', '4');
if (s.dashed) line.setAttribute('stroke-dasharray', '10 8');
svg.appendChild(line);
const txt = document.createElementNS(ns, 'text');
txt.setAttribute('x', lx + 36); txt.setAttribute('y', ly + 6);
txt.setAttribute('font-size', '22'); txt.setAttribute('font-weight', '700');
txt.textContent = s.name;
svg.appendChild(txt);
});
</script>
</body>
</html>Notes: pt(i, r) 함수로 극좌표→직교좌표 변환. -PI/2 offset으로 12시 방향 시작. Radar는 같은 스케일의 축만 섞고, focus 1개 + benchmark 1개 정도로 제한하는 편이 읽기 쉽다. 보조 시리즈는 dashed outline만 쓰고, 핵심 시리즈만 fill을 주면 겹침이 줄어든다.
---
27. Heatmap (Canvas/JS)
Best for: 2D 색상 강도, 빈도/밀도 맵, 시간별·카테고리별 분포 패턴
Approach: Canvas + JS (Network 패턴과 동일 구조). retina 2x (2880×1620). 5단계 sequential palette + hotspot 직접 라벨.
Max: 7x5 grid (35 cells), 셀 ~110x90px
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1440">
<link rel="stylesheet" href="file://{SKILL_DIR}/assets/figure.css">
<style>
body { width:1440px;height:810px;margin:0;overflow:hidden;background:#f5f5f5; }
canvas { width:1440px;height:810px; }
</style>
</head>
<body>
<canvas id="c" width="2880" height="1620"></canvas>
<script>
document.fonts.ready.then(() => {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const S = 2; // retina scale
// === CONFIG ===
const cols = ['월', '화', '수', '목', '금', '토', '일'];
const rows = ['9am', '12pm', '3pm', '6pm', '9pm'];
const values = [
[0.2, 0.6, 0.7, 0.6, 0.3, 0.1, 0.1],
[0.5, 0.9, 0.95, 0.7, 0.6, 0.2, 0.1],
[0.3, 0.7, 0.85, 0.9, 0.5, 0.15, 0.1],
[0.2, 0.4, 0.6, 0.65, 0.3, 0.1, 0.05],
[0.1, 0.2, 0.3, 0.3, 0.15, 0.05, 0.02],
];
const palette = ['#E5E7EB', '#BFDBFE', '#93C5FD', '#60A5FA', '#2563EB'];
function cellColor(v) {
if (v >= 0.85) return palette[4];
if (v >= 0.65) return palette[3];
if (v >= 0.45) return palette[2];
if (v >= 0.2) return palette[1];
return palette[0];
}
// Dot grid background
ctx.fillStyle = '#f5f5f5';
ctx.fillRect(0, 0, 2880, 1620);
ctx.fillStyle = 'rgba(10,10,10,0.06)';
for (let x = 0; x < 2880; x += 16 * S) {
for (let y = 0; y < 1620; y += 16 * S) {
ctx.beginPath();
ctx.arc(x + 8 * S, y + 8 * S, 1 * S, 0, Math.PI * 2);
ctx.fill();
}
}
// Grid layout
const ox = 320 * S, oy = 140 * S;
const cellW = 110 * S, cellH = 90 * S;
const gap = 6 * S;
// Column headers
ctx.font = `900 ${24 * S}px 'Noto Sans KR', sans-serif`;
ctx.fillStyle = '#0a0a0a';
ctx.textAlign = 'center';
cols.forEach((c, i) => {
ctx.fillText(c, ox + i * (cellW + gap) + cellW / 2, oy - 20 * S);
});
// Row headers
ctx.font = `700 ${20 * S}px 'JetBrains Mono', monospace`;
ctx.fillStyle = '#737373';
ctx.textAlign = 'right';
rows.forEach((r, j) => {
ctx.fillText(r, ox - 20 * S, oy + j * (cellH + gap) + cellH / 2 + 8 * S);
});
// Draw cells
ctx.lineWidth = 3 * S;
ctx.strokeStyle = '#0a0a0a';
values.forEach((row, j) => {
row.forEach((v, i) => {
const x = ox + i * (cellW + gap);
const y = oy + j * (cellH + gap);
ctx.fillStyle = cellColor(v);
ctx.fillRect(x, y, cellW, cellH);
ctx.strokeRect(x, y, cellW, cellH);
if (v >= 0.85) {
ctx.font = `900 ${20 * S}px 'JetBrains Mono', monospace`;
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.fillText(Math.round(v * 100), x + cellW / 2, y + cellH / 2 + 8 * S);
}
});
});
// Legend: 5-step swatches
const lx = ox, ly = oy + rows.length * (cellH + gap) + 40 * S;
const sw = 56 * S, sh = 24 * S, sg = 6 * S;
palette.forEach((color, index) => {
const x = lx + index * (sw + sg);
ctx.fillStyle = color;
ctx.fillRect(x, ly, sw, sh);
ctx.strokeRect(x, ly, sw, sh);
});
ctx.font = `700 ${20 * S}px 'Noto Sans KR', sans-serif`;
ctx.fillStyle = '#737373';
ctx.textAlign = 'left';
ctx.fillText('낮음', lx, ly + sh + 30 * S);
ctx.textAlign = 'right';
ctx.fillText('높음', lx + 5 * (sw + sg) - sg, ly + sh + 30 * S);
ctx.textAlign = 'left';
ctx.fillText('핫스팟', lx + 390 * S, ly + sh + 4 * S);
ctx.beginPath();
ctx.arc(lx + 344 * S, ly + sh - 8 * S, 12 * S, 0, Math.PI * 2);
ctx.fillStyle = '#2563EB';
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = `900 ${20 * S}px 'JetBrains Mono', monospace`;
ctx.textAlign = 'center';
ctx.fillText('95', lx + 344 * S, ly + sh - 2 * S);
});
</script>
</body>Notes: document.fonts.ready.then() 래핑 필수 (Canvas에서 Google Fonts 렌더링). retina 2x (canvas 2880×1620 → CSS 1440×810). Heatmap은 연속 그라디언트도 가능하지만, 업무용 비교 패턴은 5단계 discrete palette + hotspot 직접 라벨이 훨씬 빨리 읽힌다. 강한 셀만 숫자를 찍고 나머지는 색으로만 읽게 두는 편이 가장 안정적이다.
---
29. Sparkline Grid (SVG + JS)
Best for: 다수 항목의 트렌드 요약, 소형 라인차트 그리드, 시계열 비교 대시보드
Approach: SVG + inline `<script>` — <polyline> + <polygon> (area fill) + 굵은 엔드포인트. 프로그래밍 방식으로 그리드 생성.
Max: 6 sparklines (3x2 grid), 항목당 라벨 1단어 + 값 1개
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1440">
<link rel="stylesheet" href="file://{SKILL_DIR}/assets/figure.css">
</head>
<body>
<svg id="spark" viewBox="0 0 1440 810" xmlns="http://www.w3.org/2000/svg"
style="width:1440px;height:810px;background:#f5f5f5">
<defs>
<style>
text { font-family: 'Noto Sans KR', sans-serif; fill: #0a0a0a; }
.spark-line { fill: none; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.spark-area { stroke: none; opacity: 0.15; }
.spark-dot { stroke: #0a0a0a; stroke-width: 2.5; }
.mono { font-family: 'JetBrains Mono', monospace; }
</style>
<pattern id="dots" width="16" height="16" patternUnits="userSpaceOnUse">
<circle cx="8" cy="8" r="1" fill="rgba(10,10,10,0.06)"/>
</pattern>
</defs>
<rect width="1440" height="810" fill="url(#dots)"/>
</svg>
<script>
// === CONFIG ===
const items = [
{ name: 'React', values: [60,65,70,68,75,80,82,85], color: '#3B82F6' },
{ name: 'Vue', values: [40,42,45,50,52,55,58,62], color: '#a3e635' },
{ name: 'Svelte', values: [15,20,28,35,40,45,50,55], color: '#FF6B35' },
{ name: 'Angular', values: [70,68,65,60,55,50,48,42], color: '#ff5c8d' },
{ name: 'Solid', values: [5,8,12,18,22,28,32,35], color: '#8B5CF6' },
{ name: 'Qwik', values: [2,3,5,8,10,12,15,18], color: '#fde047' },
];
const cols = 3, rows = 2;
const margin = { left: 80, top: 60, right: 80, bottom: 60 };
const gapX = 40, gapY = 40;
const cellW = (1440 - margin.left - margin.right - (cols - 1) * gapX) / cols;
const cellH = (810 - margin.top - margin.bottom - (rows - 1) * gapY) / rows;
const padX = 20, padTop = 60, padBot = 20;
const allValues = items.flatMap(item => item.values);
const globalMin = Math.min(...allValues) * 0.9;
const globalMax = Math.max(...allValues) * 1.05;
const globalRange = globalMax - globalMin || 1;
const svg = document.getElementById('spark');
const ns = 'http://www.w3.org/2000/svg';
function el(tag, attrs) {
const e = document.createElementNS(ns, tag);
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
return e;
}
items.forEach((item, idx) => {
const col = idx % cols, row = Math.floor(idx / cols);
const cx = margin.left + col * (cellW + gapX);
const cy = margin.top + row * (cellH + gapY);
// Card background
svg.appendChild(el('rect', { x: cx + 4, y: cy + 4, width: cellW, height: cellH, fill: '#0a0a0a', opacity: '0.06' }));
svg.appendChild(el('rect', { x: cx, y: cy, width: cellW, height: cellH, fill: 'white', stroke: '#0a0a0a', 'stroke-width': '3' }));
// Chart area within card
const chartX = cx + padX, chartY = cy + padTop;
const chartW = cellW - 2 * padX, chartH = cellH - padTop - padBot;
const vals = item.values;
const n = vals.length;
const points = vals.map((v, i) => {
const x = chartX + (i / (n - 1)) * chartW;
const y = chartY + chartH - ((v - globalMin) / globalRange) * chartH;
return [x, y];
});
const lineStr = points.map(p => p.join(',')).join(' ');
// Shared baseline for cross-card comparison
svg.appendChild(el('line', {
x1: chartX,
y1: chartY + chartH,
x2: chartX + chartW,
y2: chartY + chartH,
stroke: '#d4d4d4',
'stroke-width': '2'
}));
// Area fill (polygon: line points + bottom corners)
const areaStr = lineStr + ` ${chartX + chartW},${chartY + chartH} ${chartX},${chartY + chartH}`;
svg.appendChild(el('polygon', { class: 'spark-area', points: areaStr, fill: item.color }));
// Line
svg.appendChild(el('polyline', { class: 'spark-line', points: lineStr, stroke: item.color }));
// Endpoint dot (last point, larger)
const last = points[points.length - 1];
svg.appendChild(el('circle', { class: 'spark-dot', cx: last[0], cy: last[1], r: '8', fill: item.color }));
// Label (top-left of card)
const label = el('text', { x: cx + 20, y: cy + 36, 'font-size': '24', 'font-weight': '900' });
label.textContent = item.name;
svg.appendChild(label);
// Current value (top-right of card)
const valText = el('text', { x: cx + cellW - 20, y: cy + 36, 'text-anchor': 'end', 'font-size': '22', 'font-weight': '700', class: 'mono', fill: '#737373' });
valText.textContent = vals[vals.length - 1];
svg.appendChild(valText);
const delta = vals[vals.length - 1] - vals[0];
const deltaText = el('text', {
x: cx + cellW - 20,
y: cy + cellH - 18,
'text-anchor': 'end',
'font-size': '20',
'font-weight': '700',
class: 'mono',
fill: delta >= 0 ? '#4d7c0f' : '#d6336c'
});
deltaText.textContent = `${delta >= 0 ? '+' : ''}${delta}`;
svg.appendChild(deltaText);
});
</script>
</body>
</html>Notes: 3x2 그리드로 6개 sparkline 배치. 각 셀은 white 카드 + hard shadow. <polygon>으로 area fill (opacity 0.15) + <polyline>으로 트렌드 선. 마지막 데이터 포인트에 큰 dot (r=8)을 배치하여 모바일 25% 축소에서도 인식 가능. 비교 목적의 small multiple은 shared scale을 써야 카드 간 높이 차이가 의미를 가진다. 변화량은 작은 delta 라벨로 직접 붙이고, 축은 카드 안 baseline 정도로만 남긴다.
---
Figure Patterns — Data Visualization (Static)
CSS/SVG-static patterns (no JS): Data Viz, Waffle, Slope, Dumbbell, Bullet, Waterfall.
All use assets/figure.css. 핵심 원칙:
- 텍스트는 최소화하되 직접 라벨을 우선한다.
- small multiple 비교는 shared scale을 기본값으로 둔다.
- 범례는 꼭 필요할 때만 쓰고, 가능하면 차트 안에서 설명한다.
- Radar는 동일 스케일 축 5개 이하 / 시리즈 2개 이하일 때만 사용한다.
- 모바일 25% 축소에서도 구조가 남도록, 색보다 정렬·여백·두께로 위계를 만든다.
TOC
- 14. Data Viz
- 21. Waffle
- 23. Slope
- 26. Dumbbell
- 28. Bullet
- 30. Waterfall
---
14. Data Viz (데이터 시각화)
Best for: 수치 비교, 비율, 설문 결과, 벤치마크
Key classes: .bar-chart, .bar-row, .bar-label, .bar-track, .bar-fill, .bar-value
Max: 4행
<body>
<div class="bar-chart">
<div class="bar-row">
<div class="bar-label">React</div>
<div class="bar-track"><div class="bar-fill" style="width:85%;background:var(--tl-blue)">85%</div></div>
</div>
<div class="bar-row">
<div class="bar-label">Vue</div>
<div class="bar-track"><div class="bar-fill" style="width:78%;background:var(--tl-lime)">78%</div></div>
</div>
<div class="bar-row">
<div class="bar-label">Svelte</div>
<div class="bar-track"><div class="bar-fill" style="width:92%;background:var(--tl-orange)">92%</div></div>
</div>
<div class="bar-row">
<div class="bar-label">Angular</div>
<div class="bar-track"><div class="bar-fill" style="width:54%;background:var(--tl-purple)">54%</div></div>
</div>
</div>
</body>Notes: width 퍼센트로 바 길이 설정. 라벨은 단어 1~2개.
---
21. Waffle (SVG 인라인)
Best for: 비율 체감, 퍼센트 시각화, 100칸 중 N칸으로 비율을 직관적으로 표현
Approach: SVG inline — 10x10 <rect> 그리드 (100개 셀). 셀 52x52, gap 4px.
Max: 2 카테고리, 총 ~8단어 (범례 포함)
<body>
<svg viewBox="0 0 1440 810" xmlns="http://www.w3.org/2000/svg"
style="width:1440px;height:810px;background:#f5f5f5">
<defs>
<style>
text { font-family: 'Noto Sans KR', sans-serif; fill: #0a0a0a; }
.waffle-cell { stroke: #0a0a0a; stroke-width: 2.5; }
</style>
<pattern id="dots" width="16" height="16" patternUnits="userSpaceOnUse">
<circle cx="8" cy="8" r="1" fill="rgba(10,10,10,0.06)"/>
</pattern>
</defs>
<rect width="1440" height="810" fill="url(#dots)"/>
<!-- Scale ticks -->
<text class="mono" x="350" y="112" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">0</text>
<text class="mono" x="825" y="112" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">50</text>
<text class="mono" x="1300" y="112" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">100</text>
<line x1="350" y1="120" x2="1300" y2="120" stroke="#d4d4d4" stroke-width="2"/>
<!-- Waffle grid: 10x10 = 100 cells, 72 filled (#3B82F6), 28 empty (#e5e5e5) -->
<!-- Grid generation rule:
- 10 rows (row 0-9), 10 cols (col 0-9)
- Cell origin: x = 280 + col * 56, y = 85 + row * 56
- Cell size: width="52" height="52", class="waffle-cell"
- Fill order: left-to-right, top-to-bottom (cell index = row*10 + col)
- Cells 0-71 (first 72): fill="#3B82F6" (filled/active)
- Cells 72-99 (remaining 28): fill="#e5e5e5" (empty/inactive)
- So rows 0-6 col 0-9 are all #3B82F6
- Row 7 (y=477): cols 0-1 are #3B82F6 (last 2 filled), cols 2-9 are #e5e5e5
- Rows 8-9: all #e5e5e5
-->
<!-- Example cells showing the pattern: -->
<rect class="waffle-cell" x="280" y="85" width="52" height="52" fill="#3B82F6"/> <!-- row 0, col 0 (filled) -->
<rect class="waffle-cell" x="336" y="85" width="52" height="52" fill="#3B82F6"/> <!-- row 0, col 1 (filled) -->
<rect class="waffle-cell" x="784" y="421" width="52" height="52" fill="#e5e5e5"/> <!-- row 6, col 9 (transition row) -->
<!-- Generate remaining 97 cells following the rule above -->
<!-- Legend (right side) -->
<rect x="900" y="260" width="28" height="28" fill="#3B82F6" stroke="#0a0a0a" stroke-width="2.5"/>
<text x="940" y="282" font-size="28" font-weight="900">72%</text>
<text x="940" y="316" font-size="24" font-weight="700" fill="#737373">채택</text>
<rect x="900" y="370" width="28" height="28" fill="#e5e5e5" stroke="#0a0a0a" stroke-width="2.5"/>
<text x="940" y="392" font-size="28" font-weight="900">28%</text>
<text x="940" y="426" font-size="24" font-weight="700" fill="#737373">미채택</text>
</svg>
</body>Notes: 10x10 그리드로 100% 비율 표현. 좌상단부터 채워나감. 셀 크기 52x52, gap 4px (총 560x560). 2 카테고리만 사용 (채운 셀 vs 빈 셀). fill 색상으로 카테고리 구분 (#3B82F6 = 활성, #e5e5e5 = 비활성). 퍼센트를 바꾸려면 채운 셀 수만 조정.
---
23. Slope (SVG 인라인)
Best for: 전후 정량 변화, 순위 변동, 랭킹 이동, 두 시점 비교
Approach: SVG inline — <line> + <circle> + <text>. Y좌표 = rank 기반 배치. JS 불필요.
Max: 5항목, 2시점 (좌/우 칼럼)
<body>
<svg viewBox="0 0 1440 810" xmlns="http://www.w3.org/2000/svg"
style="width:1440px;height:810px;background:#f5f5f5">
<defs>
<style>
text { font-family: 'Noto Sans KR', sans-serif; fill: #0a0a0a; }
.slope-line { stroke-width: 3; fill: none; }
.slope-dot { stroke: #0a0a0a; stroke-width: 2.5; }
.header-box { fill: white; stroke: #0a0a0a; stroke-width: 3; }
.mono { font-family: 'JetBrains Mono', monospace; }
</style>
<pattern id="dots" width="16" height="16" patternUnits="userSpaceOnUse">
<circle cx="8" cy="8" r="1" fill="rgba(10,10,10,0.06)"/>
</pattern>
</defs>
<rect width="1440" height="810" fill="url(#dots)"/>
<!-- Column headers -->
<rect class="header-box" x="240" y="60" width="180" height="56" rx="0"/>
<text x="330" y="97" text-anchor="middle" font-size="28" font-weight="900">2023</text>
<rect class="header-box" x="1020" y="60" width="180" height="56" rx="0"/>
<text x="1110" y="97" text-anchor="middle" font-size="28" font-weight="900">2024</text>
<!-- Y positions: 5 items, y = 180 + (rank-1) * 110 -->
<!-- Item 1: React — rank 1→3 (y: 180→400) -->
<line class="slope-line" x1="380" y1="180" x2="1060" y2="400" stroke="#3B82F6"/>
<circle class="slope-dot" cx="380" cy="180" r="10" fill="#3B82F6"/>
<circle class="slope-dot" cx="1060" cy="400" r="10" fill="#3B82F6"/>
<text x="360" y="186" text-anchor="end" font-size="24" font-weight="700">React</text>
<text x="220" y="186" text-anchor="end" font-size="20" font-weight="700" class="mono" fill="#737373">1</text>
<text x="1080" y="406" font-size="24" font-weight="700">React</text>
<text x="1220" y="406" font-size="20" font-weight="700" class="mono" fill="#737373">3</text>
<!-- Item 2: Svelte — rank 2→1 (y: 290→180) -->
<line class="slope-line" x1="380" y1="290" x2="1060" y2="180" stroke="#a3e635"/>
<circle class="slope-dot" cx="380" cy="290" r="10" fill="#a3e635"/>
<circle class="slope-dot" cx="1060" cy="180" r="10" fill="#a3e635"/>
<text x="360" y="296" text-anchor="end" font-size="24" font-weight="700">Svelte</text>
<text x="220" y="296" text-anchor="end" font-size="20" font-weight="700" class="mono" fill="#737373">2</text>
<text x="1080" y="186" font-size="24" font-weight="700">Svelte</text>
<text x="1220" y="186" font-size="20" font-weight="700" class="mono" fill="#737373">1</text>
<!-- Item 3: Vue — rank 3→2 (y: 400→290) -->
<line class="slope-line" x1="380" y1="400" x2="1060" y2="290" stroke="#FF6B35"/>
<circle class="slope-dot" cx="380" cy="400" r="10" fill="#FF6B35"/>
<circle class="slope-dot" cx="1060" cy="290" r="10" fill="#FF6B35"/>
<text x="360" y="406" text-anchor="end" font-size="24" font-weight="700">Vue</text>
<text x="220" y="406" text-anchor="end" font-size="20" font-weight="700" class="mono" fill="#737373">3</text>
<text x="1080" y="296" font-size="24" font-weight="700">Vue</text>
<text x="1220" y="296" font-size="20" font-weight="700" class="mono" fill="#737373">2</text>
<!-- Item 4: Angular — rank 4→5 (y: 510→620) -->
<line class="slope-line" x1="380" y1="510" x2="1060" y2="620" stroke="#ff5c8d"/>
<circle class="slope-dot" cx="380" cy="510" r="10" fill="#ff5c8d"/>
<circle class="slope-dot" cx="1060" cy="620" r="10" fill="#ff5c8d"/>
<text x="360" y="516" text-anchor="end" font-size="24" font-weight="700">Angular</text>
<text x="220" y="516" text-anchor="end" font-size="20" font-weight="700" class="mono" fill="#737373">4</text>
<text x="1080" y="626" font-size="24" font-weight="700">Angular</text>
<text x="1220" y="626" font-size="20" font-weight="700" class="mono" fill="#737373">5</text>
<!-- Item 5: Solid — rank 5→4 (y: 620→510) -->
<line class="slope-line" x1="380" y1="620" x2="1060" y2="510" stroke="#8B5CF6"/>
<circle class="slope-dot" cx="380" cy="620" r="10" fill="#8B5CF6"/>
<circle class="slope-dot" cx="1060" cy="510" r="10" fill="#8B5CF6"/>
<text x="360" y="626" text-anchor="end" font-size="24" font-weight="700">Solid</text>
<text x="220" y="626" text-anchor="end" font-size="20" font-weight="700" class="mono" fill="#737373">5</text>
<text x="1080" y="516" font-size="24" font-weight="700">Solid</text>
<text x="1220" y="516" font-size="20" font-weight="700" class="mono" fill="#737373">4</text>
</svg>
</body>Notes: Y좌표 = 180 + (rank-1) * 110 (5항목 기준). JS 불필요 — 순수 SVG. --tl-* 팔레트에서 항목별 색상 배정. 좌 칼럼 = 이전 시점, 우 칼럼 = 이후 시점. 축보다 endpoint 직접 라벨을 우선하고, 5개 이내만 남겨 선 교차를 통제한다. 가능하면 오른쪽 결과값 기준으로 정렬해 읽는 부담을 줄인다.
---
26. Dumbbell (SVG 인라인)
Best for: 두 값 사이의 갭/범위 비교, 전후 변화량, 격차 시각화
Approach: SVG inline — <circle> x2 + <line> per row. 공유 x축 선형 매핑. JS 불필요.
Max: 5 rows, 라벨 max 3단어
<body>
<svg viewBox="0 0 1440 810" xmlns="http://www.w3.org/2000/svg"
style="width:1440px;height:810px;background:#f5f5f5">
<defs>
<style>
text { font-family: 'Noto Sans KR', sans-serif; fill: #0a0a0a; }
.db-line { stroke: #e5e5e5; stroke-width: 4; stroke-linecap: round; }
.db-dot { stroke: #0a0a0a; stroke-width: 2.5; }
.mono { font-family: 'JetBrains Mono', monospace; }
</style>
<pattern id="dots" width="16" height="16" patternUnits="userSpaceOnUse">
<circle cx="8" cy="8" r="1" fill="rgba(10,10,10,0.06)"/>
</pattern>
</defs>
<rect width="1440" height="810" fill="url(#dots)"/>
<!-- Legend (top) -->
<circle cx="860" cy="60" r="10" fill="#3B82F6" stroke="#0a0a0a" stroke-width="2.5"/>
<text x="880" y="67" font-size="22" font-weight="700">2023</text>
<circle cx="1000" cy="60" r="10" fill="#FF6B35" stroke="#0a0a0a" stroke-width="2.5"/>
<text x="1020" y="67" font-size="22" font-weight="700">2024</text>
<!-- X axis scale: 0%—100%, mapped to x: 400—1300 -->
<!-- Scale ticks -->
<line x1="400" y1="740" x2="1300" y2="740" stroke="#e5e5e5" stroke-width="1.5"/>
<text class="mono" x="400" y="770" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">0%</text>
<text class="mono" x="625" y="770" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">25%</text>
<text class="mono" x="850" y="770" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">50%</text>
<text class="mono" x="1075" y="770" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">75%</text>
<text class="mono" x="1300" y="770" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">100%</text>
<!-- Row 1: React — 2023: 72%, 2024: 85% → x: 1048, 1165 -->
<text x="360" y="180" text-anchor="end" font-size="24" font-weight="700">React</text>
<line class="db-line" x1="1048" y1="174" x2="1165" y2="174"/>
<circle class="db-dot" cx="1048" cy="174" r="12" fill="#3B82F6"/>
<circle class="db-dot" cx="1165" cy="174" r="12" fill="#FF6B35"/>
<text class="mono" x="1030" y="153" text-anchor="end" font-size="20" font-weight="700" fill="#3B82F6">72</text>
<text class="mono" x="1185" y="153" font-size="20" font-weight="700" fill="#FF6B35">85</text>
<text class="mono" x="1208" y="180" font-size="20" font-weight="700" fill="#4d7c0f">+13</text>
<!-- Row 2: Vue — 2023: 45%, 2024: 62% → x: 805, 958 -->
<text x="360" y="310" text-anchor="end" font-size="24" font-weight="700">Vue</text>
<line class="db-line" x1="805" y1="304" x2="958" y2="304"/>
<circle class="db-dot" cx="805" cy="304" r="12" fill="#3B82F6"/>
<circle class="db-dot" cx="958" cy="304" r="12" fill="#FF6B35"/>
<text class="mono" x="787" y="283" text-anchor="end" font-size="20" font-weight="700" fill="#3B82F6">45</text>
<text class="mono" x="978" y="283" font-size="20" font-weight="700" fill="#FF6B35">62</text>
<text class="mono" x="1000" y="310" font-size="20" font-weight="700" fill="#4d7c0f">+17</text>
<!-- Row 3: Svelte — 2023: 28%, 2024: 55% → x: 652, 895 -->
<text x="360" y="440" text-anchor="end" font-size="24" font-weight="700">Svelte</text>
<line class="db-line" x1="652" y1="434" x2="895" y2="434"/>
<circle class="db-dot" cx="652" cy="434" r="12" fill="#3B82F6"/>
<circle class="db-dot" cx="895" cy="434" r="12" fill="#FF6B35"/>
<text class="mono" x="634" y="413" text-anchor="end" font-size="20" font-weight="700" fill="#3B82F6">28</text>
<text class="mono" x="915" y="413" font-size="20" font-weight="700" fill="#FF6B35">55</text>
<text class="mono" x="940" y="440" font-size="20" font-weight="700" fill="#4d7c0f">+27</text>
<!-- Row 4: Angular — 2023: 60%, 2024: 42% → x: 940, 778 -->
<text x="360" y="570" text-anchor="end" font-size="24" font-weight="700">Angular</text>
<line class="db-line" x1="778" y1="564" x2="940" y2="564"/>
<circle class="db-dot" cx="940" cy="564" r="12" fill="#3B82F6"/>
<circle class="db-dot" cx="778" cy="564" r="12" fill="#FF6B35"/>
<text class="mono" x="960" y="543" font-size="20" font-weight="700" fill="#3B82F6">60</text>
<text class="mono" x="758" y="543" text-anchor="end" font-size="20" font-weight="700" fill="#FF6B35">42</text>
<text class="mono" x="980" y="570" font-size="20" font-weight="700" fill="#d6336c">-18</text>
<!-- Row 5: Solid — 2023: 10%, 2024: 35% → x: 490, 715 -->
<text x="360" y="670" text-anchor="end" font-size="24" font-weight="700">Solid</text>
<line class="db-line" x1="490" y1="664" x2="715" y2="664"/>
<circle class="db-dot" cx="490" cy="664" r="12" fill="#3B82F6"/>
<circle class="db-dot" cx="715" cy="664" r="12" fill="#FF6B35"/>
<text class="mono" x="472" y="643" text-anchor="end" font-size="20" font-weight="700" fill="#3B82F6">10</text>
<text class="mono" x="735" y="643" font-size="20" font-weight="700" fill="#FF6B35">35</text>
<text class="mono" x="758" y="670" font-size="20" font-weight="700" fill="#4d7c0f">+25</text>
</svg>
</body>Notes: x축 범위 400~1300px, 값 0~100%를 선형 매핑 (x = 400 + value/100 * 900). 각 행에 연결 선(muted) + 양끝 dot(colored)으로 갭 시각화. endpoint 값과 delta를 직접 라벨링하면 범례보다 훨씬 빨리 읽힌다. 비교용 dumbbell은 보통 최신값이나 변화량 기준 정렬이 가장 자연스럽다.
---
28. Bullet (SVG 인라인)
Best for: 실적 vs 목표, KPI 달성률, 맥락 포함 정량 지표
Approach: SVG inline — 중첩 <rect> (배경 범위 3단계) + 전경 actual 바 + 타겟 <line>.
Max: 3~4 charts 수직 배치
<body>
<svg viewBox="0 0 1440 810" xmlns="http://www.w3.org/2000/svg"
style="width:1440px;height:810px;background:#f5f5f5">
<defs>
<style>
text { font-family: 'Noto Sans KR', sans-serif; fill: #0a0a0a; }
.mono { font-family: 'JetBrains Mono', monospace; }
.range { stroke: none; }
.actual { stroke: #0a0a0a; stroke-width: 3; }
.target { stroke: #0a0a0a; stroke-width: 4; }
</style>
<pattern id="dots" width="16" height="16" patternUnits="userSpaceOnUse">
<circle cx="8" cy="8" r="1" fill="rgba(10,10,10,0.06)"/>
</pattern>
</defs>
<rect width="1440" height="810" fill="url(#dots)"/>
<!-- Bullet chart layout: x range 350-1300, bar height 60, spacing 190 -->
<!-- Scale: 0-100 mapped to x: 350-1300 (950px range) -->
<!-- Chart 1: 매출 — ranges: poor 0-40, ok 40-75, good 75-100 | actual: 82 | target: 90 -->
<text x="320" y="180" text-anchor="end" font-size="28" font-weight="900">매출</text>
<!-- Range backgrounds (back-to-front: widest=lightest first) -->
<rect class="range" x="350" y="150" width="950" height="60" fill="#e5e5e5"/>
<rect class="range" x="350" y="150" width="713" height="60" fill="#d4d4d4"/>
<rect class="range" x="350" y="150" width="380" height="60" fill="#a3a3a3"/>
<!-- Actual bar (narrower: 60% height = 36px, centered) -->
<rect class="actual" x="350" y="162" width="779" height="36" fill="#3B82F6"/>
<!-- Target marker -->
<line class="target" x1="1205" y1="145" x2="1205" y2="215"/>
<text class="mono" x="1205" y="238" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">90</text>
<text class="mono" x="1145" y="187" text-anchor="start" font-size="20" font-weight="700" fill="#3B82F6">82</text>
<!-- Chart 2: 성장률 — ranges: poor 0-30, ok 30-60, good 60-100 | actual: 68 | target: 50 -->
<text x="320" y="370" text-anchor="end" font-size="28" font-weight="900">성장률</text>
<rect class="range" x="350" y="340" width="950" height="60" fill="#e5e5e5"/>
<rect class="range" x="350" y="340" width="570" height="60" fill="#d4d4d4"/>
<rect class="range" x="350" y="340" width="285" height="60" fill="#a3a3a3"/>
<rect class="actual" x="350" y="352" width="646" height="36" fill="#3B82F6"/>
<line class="target" x1="825" y1="335" x2="825" y2="405"/>
<text class="mono" x="825" y="428" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">50</text>
<text class="mono" x="1012" y="377" font-size="20" font-weight="700" fill="#3B82F6">68</text>
<!-- Chart 3: NPS — ranges: poor 0-30, ok 30-70, good 70-100 | actual: 55 | target: 70 -->
<text x="320" y="560" text-anchor="end" font-size="28" font-weight="900">NPS</text>
<rect class="range" x="350" y="530" width="950" height="60" fill="#e5e5e5"/>
<rect class="range" x="350" y="530" width="665" height="60" fill="#d4d4d4"/>
<rect class="range" x="350" y="530" width="285" height="60" fill="#a3a3a3"/>
<rect class="actual" x="350" y="542" width="523" height="36" fill="#3B82F6"/>
<line class="target" x1="1015" y1="525" x2="1015" y2="595"/>
<text class="mono" x="1015" y="618" text-anchor="middle" font-size="20" font-weight="700" fill="#737373">70</text>
<text class="mono" x="889" y="567" font-size="20" font-weight="700" fill="#3B82F6">55</text>
<!-- Range labels (single row) -->
<text x="350" y="720" font-size="20" font-weight="700" fill="#737373">정성 범위</text>
<rect x="470" y="700" width="24" height="20" fill="#a3a3a3"/>
<text x="506" y="717" font-size="20" font-weight="700" fill="#737373">부진</text>
<rect x="576" y="700" width="24" height="20" fill="#d4d4d4"/>
<text x="612" y="717" font-size="20" font-weight="700" fill="#737373">보통</text>
<rect x="682" y="700" width="24" height="20" fill="#e5e5e5"/>
<text x="718" y="717" font-size="20" font-weight="700" fill="#737373">양호</text>
<line x1="816" y1="700" x2="816" y2="722" stroke="#0a0a0a" stroke-width="4"/>
<text x="834" y="717" font-size="20" font-weight="700" fill="#737373">목표</text>
</svg>
</body>Notes: Back-to-front 레이어링으로 3단계 범위 표현 (가장 넓은 rect = lightest, 점점 좁고 진함). Actual 바는 전체 높이의 60% (36px/60px)로 범위 배경 위에 겹침. Target marker는 <line> (stroke-width: 4). x축 스케일: x = 350 + (value/100) * 950. 정성 구간은 muted, actual은 일관된 단일 색상, 값은 바 끝에 직접 표기하는 편이 가장 읽기 쉽다.
---
30. Waterfall (SVG 인라인)
Best for: 증감 분해, 누적 변화, 수익 분석, 요인별 기여도
Approach: SVG inline — floating <rect> (증가/감소) + grounded <rect> (시작/합계) + connector <line>. JS 불필요.
Max: 6~8 bars, 라벨 1~2단어
<body>
<svg viewBox="0 0 1440 810" xmlns="http://www.w3.org/2000/svg"
style="width:1440px;height:810px;background:#f5f5f5">
<defs>
<style>
text { font-family: 'Noto Sans KR', sans-serif; fill: #0a0a0a; }
.bar { stroke: #0a0a0a; stroke-width: 3; }
.bar-sh { fill: #0a0a0a; opacity: 0.08; stroke: none; }
.connector { stroke: #a3a3a3; stroke-width: 1.5; stroke-dasharray: 6 4; }
.mono { font-family: 'JetBrains Mono', monospace; }
.pos { fill: #a3e635; }
.neg { fill: #ff5c8d; }
.total { fill: #3B82F6; }
</style>
<pattern id="dots" width="16" height="16" patternUnits="userSpaceOnUse">
<circle cx="8" cy="8" r="1" fill="rgba(10,10,10,0.06)"/>
</pattern>
</defs>
<rect width="1440" height="810" fill="url(#dots)"/>
<!-- Scale: value 0-180 → y: 650→120 (530px). y(v) = 650 - (v/180)*530 -->
<!-- Bar width: 120px, spacing: 180px, starting x: 200 -->
<!-- Data: Start=100, +Revenue=40, +Services=25, -COGS=30, -OpEx=20, Total=115 -->
<!-- Bar 1: 시작 (grounded: 0→100) y(100)=356, h=294 -->
<rect class="bar-sh" x="204" y="360" width="120" height="294"/>
<rect class="bar total" x="200" y="356" width="120" height="294"/>
<text class="mono" x="260" y="340" text-anchor="middle" font-size="22" font-weight="700">100</text>
<text x="260" y="700" text-anchor="middle" font-size="22" font-weight="900">시작</text>
<!-- Connector: y=356 from x=320 to x=380 -->
<line class="connector" x1="320" y1="356" x2="380" y2="356"/>
<!-- Bar 2: +매출 (floating: 100→140) y(140)=238, h=118 -->
<rect class="bar-sh" x="384" y="242" width="120" height="118"/>
<rect class="bar pos" x="380" y="238" width="120" height="118"/>
<text class="mono" x="440" y="222" text-anchor="middle" font-size="22" font-weight="700" fill="#4d7c0f">+40</text>
<text x="440" y="700" text-anchor="middle" font-size="22" font-weight="900">매출</text>
<!-- Connector: y=238 from x=500 to x=560 -->
<line class="connector" x1="500" y1="238" x2="560" y2="238"/>
<!-- Bar 3: +서비스 (floating: 140→165) y(165)=164, h=74 -->
<rect class="bar-sh" x="564" y="168" width="120" height="74"/>
<rect class="bar pos" x="560" y="164" width="120" height="74"/>
<text class="mono" x="620" y="148" text-anchor="middle" font-size="22" font-weight="700" fill="#4d7c0f">+25</text>
<text x="620" y="700" text-anchor="middle" font-size="22" font-weight="900">서비스</text>
<!-- Connector: y=164 from x=680 to x=740 -->
<line class="connector" x1="680" y1="164" x2="740" y2="164"/>
<!-- Bar 4: -원가 (floating: 165→135) top=y(165)=164, bottom=y(135)=252, h=88 -->
<rect class="bar-sh" x="744" y="168" width="120" height="88"/>
<rect class="bar neg" x="740" y="164" width="120" height="88"/>
<text class="mono" x="800" y="272" text-anchor="middle" font-size="22" font-weight="700" fill="#d6336c">-30</text>
<text x="800" y="700" text-anchor="middle" font-size="22" font-weight="900">원가</text>
<!-- Connector: y=252 from x=860 to x=920 -->
<line class="connector" x1="860" y1="252" x2="920" y2="252"/>
<!-- Bar 5: -운영비 (floating: 135→115) top=y(135)=252, bottom=y(115)=311, h=59 -->
<rect class="bar-sh" x="924" y="256" width="120" height="59"/>
<rect class="bar neg" x="920" y="252" width="120" height="59"/>
<text class="mono" x="980" y="331" text-anchor="middle" font-size="22" font-weight="700" fill="#d6336c">-20</text>
<text x="980" y="700" text-anchor="middle" font-size="22" font-weight="900">운영비</text>
<!-- Connector: y=311 from x=1040 to x=1100 -->
<line class="connector" x1="1040" y1="311" x2="1100" y2="311"/>
<!-- Bar 6: 합계 (grounded: 0→115) y(115)=311, h=339 -->
<rect class="bar-sh" x="1104" y="315" width="120" height="339"/>
<rect class="bar total" x="1100" y="311" width="120" height="339"/>
<text class="mono" x="1160" y="295" text-anchor="middle" font-size="22" font-weight="700">115</text>
<text x="1160" y="700" text-anchor="middle" font-size="22" font-weight="900">합계</text>
<!-- Baseline -->
<line x1="170" y1="650" x2="1250" y2="650" stroke="#e5e5e5" stroke-width="1.5"/>
<!-- Legend -->
<rect x="200" y="760" width="20" height="20" fill="#a3e635" stroke="#0a0a0a" stroke-width="2"/>
<text x="230" y="777" font-size="20" font-weight="700" fill="#737373">증가</text>
<rect x="310" y="760" width="20" height="20" fill="#ff5c8d" stroke="#0a0a0a" stroke-width="2"/>
<text x="340" y="777" font-size="20" font-weight="700" fill="#737373">감소</text>
<rect x="420" y="760" width="20" height="20" fill="#3B82F6" stroke="#0a0a0a" stroke-width="2"/>
<text x="450" y="777" font-size="20" font-weight="700" fill="#737373">합계</text>
</svg>
</body>Notes: Floating rect로 증감 표현 — 증가 바는 이전 누적값 위에 쌓이고, 감소 바는 이전 누적값에서 아래로 내려감. Grounded bar (시작/합계)는 baseline(y=650)에서 시작. Connector (dashed line)가 이전 바의 끝점과 다음 바의 시작점을 연결. 값 라벨: 증가는 바 위 (#4d7c0f), 감소는 바 아래 (#d6336c). y(v) = 650 - (v/180) * 530 스케일. JS 불필요.
---