Files

1300 lines
55 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Build diagram-only TechViz example fixtures.
The fixtures deliberately avoid poster-like decoration. The visible SVG canvas
contains only diagram semantics: nodes, boundaries, connectors, labels, states,
and annotations required to decode the technical relationship.
"""
from __future__ import annotations
import hashlib
import html
import json
import math
import shutil
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Sequence
import cairosvg
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent
FONT = '"Noto Sans CJK KR", "Apple SD Gothic Neo", sans-serif'
MONO = '"Noto Sans Mono CJK KR", "D2Coding", monospace'
INK = "#24272B"
MUTED = "#667085"
LIGHT = "#F5F6F7"
LINE = "#A8AFB8"
BLUE = "#1677FF"
BLUE_DARK = "#0B5CC4"
BLUE_LIGHT = "#DDF1FF"
GREEN = "#00A86B"
GREEN_LIGHT = "#E9F8F0"
RED = "#D94B4B"
RED_LIGHT = "#FDEEEE"
PURPLE = "#7556D8"
WHITE = "#FFFFFF"
BLACK = "#000000"
YELLOW = "#E7C51D"
def esc(value: object) -> str:
return html.escape(str(value), quote=True)
def fmt(value: float | int) -> str:
if isinstance(value, float) and not value.is_integer():
return f"{value:.1f}"
return str(int(value))
def approx_text_width(value: str, size: float) -> float:
units = 0.0
for ch in value:
if ch.isspace():
units += 0.35
elif unicodedata.east_asian_width(ch) in {"W", "F", "A"}:
units += 1.0
elif ch.isupper():
units += 0.66
else:
units += 0.55
return max(size, units * size)
@dataclass(frozen=True)
class Example:
folder: str
filename: str
name: str
profile: str
question: str
desc: str
width: int
height: int
build: Callable[["Svg"], None]
class Svg:
def __init__(self, width: int, height: int, *, title: str, desc: str, background: str = WHITE):
self.width = width
self.height = height
self.title = title
self.desc = desc
self.background = background
self.items: list[str] = []
self.defs: list[str] = []
self._add_default_defs()
def _add_default_defs(self) -> None:
for marker_id, color in [
("arrow-ink", INK),
("arrow-muted", MUTED),
("arrow-blue", BLUE),
("arrow-green", GREEN),
("arrow-red", RED),
("arrow-purple", PURPLE),
("arrow-white", WHITE),
("arrow-yellow", YELLOW),
]:
self.defs.append(
f'<marker id="{marker_id}" markerWidth="10" markerHeight="8" '
f'refX="9" refY="4" orient="auto" markerUnits="strokeWidth">'
f'<path d="M 0 0 L 10 4 L 0 8 z" fill="{color}"/></marker>'
)
def add(self, value: str) -> None:
self.items.append(value)
def text(
self,
x: float,
y: float,
value: str,
*,
size: int = 16,
weight: int = 500,
fill: str = INK,
anchor: str = "start",
family: str = FONT,
opacity: float = 1.0,
italic: bool = False,
letter_spacing: float | None = None,
) -> None:
attrs = [
f'x="{fmt(x)}"',
f'y="{fmt(y)}"',
f'font-family={esc(family)!r}',
f'font-size="{size}"',
f'font-weight="{weight}"',
f'fill="{fill}"',
f'text-anchor="{anchor}"',
]
if opacity != 1.0:
attrs.append(f'opacity="{opacity}"')
if italic:
attrs.append('font-style="italic"')
if letter_spacing is not None:
attrs.append(f'letter-spacing="{letter_spacing}"')
self.add(f"<text {' '.join(attrs)}>{esc(value)}</text>")
def multiline(
self,
x: float,
y: float,
lines: Sequence[str],
*,
size: int = 16,
weight: int = 500,
fill: str = INK,
anchor: str = "start",
family: str = FONT,
line_height: float = 1.35,
) -> None:
self.add(
f'<text x="{fmt(x)}" y="{fmt(y)}" font-family={esc(family)!r} '
f'font-size="{size}" font-weight="{weight}" fill="{fill}" text-anchor="{anchor}">'
)
for index, line in enumerate(lines):
dy = 0 if index == 0 else size * line_height
self.add(f'<tspan x="{fmt(x)}" dy="{fmt(dy)}">{esc(line)}</tspan>')
self.add("</text>")
def rect(
self,
x: float,
y: float,
w: float,
h: float,
*,
fill: str = "none",
stroke: str = "none",
sw: float = 0,
rx: float = 0,
dash: str | None = None,
opacity: float = 1.0,
) -> None:
attrs = [
f'x="{fmt(x)}"',
f'y="{fmt(y)}"',
f'width="{fmt(w)}"',
f'height="{fmt(h)}"',
f'fill="{fill}"',
]
if stroke != "none":
attrs.extend([f'stroke="{stroke}"', f'stroke-width="{fmt(sw)}"'])
if rx:
attrs.append(f'rx="{fmt(rx)}"')
if dash:
attrs.append(f'stroke-dasharray="{dash}"')
if opacity != 1.0:
attrs.append(f'opacity="{opacity}"')
self.add(f"<rect {' '.join(attrs)} />")
def circle(
self,
cx: float,
cy: float,
r: float,
*,
fill: str = "none",
stroke: str = "none",
sw: float = 0,
opacity: float = 1.0,
) -> None:
attrs = [f'cx="{fmt(cx)}"', f'cy="{fmt(cy)}"', f'r="{fmt(r)}"', f'fill="{fill}"']
if stroke != "none":
attrs.extend([f'stroke="{stroke}"', f'stroke-width="{fmt(sw)}"'])
if opacity != 1.0:
attrs.append(f'opacity="{opacity}"')
self.add(f"<circle {' '.join(attrs)} />")
def ellipse(
self,
cx: float,
cy: float,
rx: float,
ry: float,
*,
fill: str = "none",
stroke: str = "none",
sw: float = 0,
) -> None:
attrs = [
f'cx="{fmt(cx)}"',
f'cy="{fmt(cy)}"',
f'rx="{fmt(rx)}"',
f'ry="{fmt(ry)}"',
f'fill="{fill}"',
]
if stroke != "none":
attrs.extend([f'stroke="{stroke}"', f'stroke-width="{fmt(sw)}"'])
self.add(f"<ellipse {' '.join(attrs)} />")
def polygon(
self,
points: Iterable[tuple[float, float]],
*,
fill: str = "none",
stroke: str = "none",
sw: float = 0,
) -> None:
value = " ".join(f"{fmt(x)},{fmt(y)}" for x, y in points)
attrs = [f'points="{value}"', f'fill="{fill}"']
if stroke != "none":
attrs.extend([f'stroke="{stroke}"', f'stroke-width="{fmt(sw)}"'])
self.add(f"<polygon {' '.join(attrs)} />")
def path(
self,
d: str,
*,
stroke: str = INK,
sw: float = 2,
fill: str = "none",
dash: str | None = None,
marker: str | None = None,
opacity: float = 1.0,
linecap: str = "round",
linejoin: str = "round",
) -> None:
attrs = [
f'd="{d}"',
f'fill="{fill}"',
f'stroke="{stroke}"',
f'stroke-width="{fmt(sw)}"',
f'stroke-linecap="{linecap}"',
f'stroke-linejoin="{linejoin}"',
]
if dash:
attrs.append(f'stroke-dasharray="{dash}"')
if marker:
attrs.append(f'marker-end="url(#{marker})"')
if opacity != 1.0:
attrs.append(f'opacity="{opacity}"')
self.add(f"<path {' '.join(attrs)} />")
def line(
self,
x1: float,
y1: float,
x2: float,
y2: float,
*,
stroke: str = INK,
sw: float = 2,
dash: str | None = None,
marker: str | None = None,
opacity: float = 1.0,
) -> None:
self.path(
f"M {fmt(x1)} {fmt(y1)} L {fmt(x2)} {fmt(y2)}",
stroke=stroke,
sw=sw,
dash=dash,
marker=marker,
opacity=opacity,
)
def polyline(
self,
points: Sequence[tuple[float, float]],
*,
stroke: str = INK,
sw: float = 2,
dash: str | None = None,
marker: str | None = None,
opacity: float = 1.0,
) -> None:
d = "M " + " L ".join(f"{fmt(x)} {fmt(y)}" for x, y in points)
self.path(d, stroke=stroke, sw=sw, dash=dash, marker=marker, opacity=opacity)
def label(
self,
x: float,
y: float,
value: str,
*,
size: int = 13,
fill: str = INK,
bg: str | None = None,
stroke: str | None = None,
padding_x: float = 7,
padding_y: float = 4,
anchor: str = "middle",
weight: int = 600,
) -> None:
width = approx_text_width(value, size) + padding_x * 2
height = size + padding_y * 2 + 1
if bg is not None:
left = x - width / 2 if anchor == "middle" else x - padding_x
self.rect(left, y - size - padding_y + 2, width, height, fill=bg, stroke=stroke or "none", sw=1 if stroke else 0, rx=2)
self.text(x, y, value, size=size, weight=weight, fill=fill, anchor=anchor)
def box(
self,
x: float,
y: float,
w: float,
h: float,
title: str,
*,
subtitle: str | None = None,
fill: str = WHITE,
stroke: str = INK,
sw: float = 1.6,
rx: float = 8,
title_size: int = 17,
subtitle_size: int = 12,
title_fill: str = INK,
subtitle_fill: str = MUTED,
role: str | None = None,
center: bool = True,
) -> None:
self.rect(x, y, w, h, fill=fill, stroke=stroke, sw=sw, rx=rx)
anchor = "middle" if center else "start"
tx = x + w / 2 if center else x + 18
if role:
self.text(tx, y + 22, role, size=10, weight=500, fill=subtitle_fill, anchor=anchor, letter_spacing=1.1)
title_y = y + (h / 2 + 5 if subtitle is None else h / 2 - 2)
else:
title_y = y + (h / 2 + 5 if subtitle is None else h / 2 - 8)
self.text(tx, title_y, title, size=title_size, weight=700, fill=title_fill, anchor=anchor)
if subtitle:
self.text(tx, title_y + 27, subtitle, size=subtitle_size, weight=450, fill=subtitle_fill, anchor=anchor)
def group_box(
self,
x: float,
y: float,
w: float,
h: float,
label: str,
*,
stroke: str = LINE,
fill: str = "none",
dash: str | None = "7 6",
label_fill: str = MUTED,
sw: float = 1.3,
) -> None:
self.rect(x, y, w, h, fill=fill, stroke=stroke, sw=sw, rx=10, dash=dash)
self.label(x + 14, y + 5, label, size=11, fill=label_fill, bg=self.background, anchor="start", weight=600)
def database(
self,
x: float,
y: float,
w: float,
h: float,
title: str,
*,
fill: str = WHITE,
stroke: str = INK,
sw: float = 1.6,
title_fill: str = INK,
subtitle: str | None = None,
) -> None:
ry = 11
self.rect(x, y + ry, w, h - 2 * ry, fill=fill, stroke="none")
self.ellipse(x + w / 2, y + ry, w / 2, ry, fill=fill, stroke=stroke, sw=sw)
self.line(x, y + ry, x, y + h - ry, stroke=stroke, sw=sw)
self.line(x + w, y + ry, x + w, y + h - ry, stroke=stroke, sw=sw)
self.path(
f"M {fmt(x)} {fmt(y + h - ry)} C {fmt(x + w * .2)} {fmt(y + h + 2)}, {fmt(x + w * .8)} {fmt(y + h + 2)}, {fmt(x + w)} {fmt(y + h - ry)}",
stroke=stroke,
sw=sw,
)
self.text(x + w / 2, y + h / 2 + 3, title, size=16, weight=700, fill=title_fill, anchor="middle")
if subtitle:
self.text(x + w / 2, y + h / 2 + 25, subtitle, size=11, weight=450, fill=MUTED, anchor="middle")
def document(
self,
x: float,
y: float,
w: float,
h: float,
*,
stroke: str = INK,
fill: str = WHITE,
sw: float = 1.8,
fold: float = 45,
) -> None:
points = [(x, y), (x + w - fold, y), (x + w, y + fold), (x + w, y + h), (x, y + h)]
self.polygon(points, fill=fill, stroke=stroke, sw=sw)
self.polyline([(x + w - fold, y), (x + w - fold, y + fold), (x + w, y + fold)], stroke=stroke, sw=sw)
def server(
self,
x: float,
y: float,
w: float,
h: float,
*,
stroke: str = INK,
fill: str = WHITE,
sw: float = 1.8,
rows: int = 3,
accent: str | None = None,
) -> None:
self.rect(x, y, w, h, fill=fill, stroke=stroke, sw=sw, rx=2)
row_h = h / rows
for i in range(1, rows):
self.line(x, y + i * row_h, x + w, y + i * row_h, stroke=stroke, sw=sw)
for i in range(rows):
color = accent or stroke
self.rect(x + 20, y + i * row_h + row_h / 2 - 5, 10, 10, fill=color)
def person(self, x: float, y: float, *, stroke: str = INK, sw: float = 2.2, scale: float = 1.0) -> None:
self.circle(x, y, 12 * scale, stroke=stroke, sw=sw)
self.path(
f"M {fmt(x - 25 * scale)} {fmt(y + 48 * scale)} C {fmt(x - 22 * scale)} {fmt(y + 22 * scale)}, {fmt(x + 22 * scale)} {fmt(y + 22 * scale)}, {fmt(x + 25 * scale)} {fmt(y + 48 * scale)}",
stroke=stroke,
sw=sw,
)
self.line(x - 25 * scale, y + 48 * scale, x + 25 * scale, y + 48 * scale, stroke=stroke, sw=sw)
def phone(self, x: float, y: float, w: float, h: float, *, stroke: str = INK, fill: str = WHITE) -> None:
self.rect(x, y, w, h, fill=fill, stroke=stroke, sw=1.7, rx=10)
self.line(x + 12, y + 22, x + w - 12, y + 22, stroke=stroke, sw=1.1)
self.line(x + 12, y + h - 26, x + w - 12, y + h - 26, stroke=stroke, sw=1.1)
self.circle(x + w / 2, y + h - 13, 3, fill=stroke)
def x_mark(self, cx: float, cy: float, size: float, *, stroke: str = RED, sw: float = 8) -> None:
self.line(cx - size, cy - size, cx + size, cy + size, stroke=stroke, sw=sw)
self.line(cx + size, cy - size, cx - size, cy + size, stroke=stroke, sw=sw)
def render(self) -> str:
metadata = json.dumps(
{
"generator": "examples/build_examples.py",
"version": "0.3.0",
"canvas_policy": "diagram-only",
"decorative_effects": False,
},
ensure_ascii=False,
separators=(",", ":"),
)
return "\n".join(
[
'<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" role="img" aria-labelledby="title desc">',
f'<title id="title">{esc(self.title)}</title>',
f'<desc id="desc">{esc(self.desc)}</desc>',
f'<metadata>{esc(metadata)}</metadata>',
"<defs>",
*self.defs,
"</defs>",
f'<rect width="{self.width}" height="{self.height}" fill="{self.background}"/>',
*self.items,
"</svg>",
]
)
# ---------------------------------------------------------------------------
# Diagram builders
# ---------------------------------------------------------------------------
def build_payment_flow(s: Svg) -> None:
s.group_box(300, 58, 1050, 500, "Checkout system", stroke="#B8BEC7", dash="8 7")
s.rect(45, 248, 170, 82, fill=WHITE, stroke="#67717D", sw=1.5, rx=8, dash="7 6")
s.text(130, 279, "Client", size=17, weight=700, anchor="middle")
s.text(130, 306, "web / mobile", size=12, weight=450, fill=MUTED, anchor="middle")
s.box(350, 248, 200, 82, "Auth Gateway", subtitle="request validation", fill="#FAFAFA", stroke="#4B5563")
s.box(660, 220, 220, 135, "Checkout API", subtitle="payment orchestration", fill="#F7FBFF", stroke=BLUE_DARK, sw=2)
s.database(1040, 205, 230, 96, "Order DB", subtitle="order state", fill="#F7FBFF", stroke=BLUE_DARK)
s.box(1080, 78, 190, 72, "Event Bus", subtitle="payment.approved", fill="#FAFAFA", stroke="#4B5563")
s.box(1080, 430, 190, 82, "Payment Provider", subtitle="external", fill="#FAFAFA", stroke="#4B5563")
s.line(215, 289, 350, 289, stroke=INK, sw=2, marker="arrow-ink")
s.label(282, 278, "HTTPS", size=12, bg=WHITE)
s.line(550, 289, 660, 289, stroke=INK, sw=2, marker="arrow-ink")
s.label(605, 278, "검증된 요청", size=12, bg=WHITE)
s.polyline([(880, 255), (955, 255), (955, 235), (1040, 235)], stroke=BLUE_DARK, sw=2, marker="arrow-blue")
s.label(957, 225, "PENDING 저장", size=12, fill=BLUE_DARK, bg=WHITE)
s.polyline([(880, 315), (970, 315), (970, 270), (1040, 270)], stroke=BLUE_DARK, sw=2, marker="arrow-blue")
s.label(962, 304, "PAID 갱신", size=12, fill=BLUE_DARK, bg=WHITE)
s.polyline([(815, 220), (815, 113), (1080, 113)], stroke=PURPLE, sw=2, dash="7 5", marker="arrow-purple")
s.label(947, 101, "payment.approved", size=12, fill=PURPLE, bg=WHITE)
s.polyline([(880, 335), (955, 335), (955, 471), (1080, 471)], stroke=INK, sw=2, marker="arrow-ink")
s.label(982, 459, "승인 요청", size=12, bg=WHITE)
s.polyline([(1080, 498), (1010, 498), (1010, 390), (770, 390), (770, 355)], stroke=MUTED, sw=1.8, dash="6 5", marker="arrow-muted")
s.label(900, 381, "승인 응답", size=12, fill=MUTED, bg=WHITE)
def build_orchestrator_workers(s: Svg) -> None:
s.box(160, 35, 1120, 108, "MAIN SESSION", subtitle="context + log", role="<<orchestrator>>", stroke="#555B63", fill="#FCFCFC")
s.group_box(60, 245, 380, 350, "<<workers>> × N · fan-out", stroke="#A7ADB5", dash="7 5")
s.text(250, 282, "SUBAGENTS", size=18, weight=700, anchor="middle")
worker_rows = [
"Client RPS · latency",
"Server RPS · latency",
"JVM heap · GC",
"Client pool",
"system resources",
]
for i, label in enumerate(worker_rows):
y = 305 + i * 50
s.rect(100, y, 300, 38, fill=WHITE, stroke="#B6BBC2", sw=1.2, rx=5)
s.text(118, y + 24, "SA", size=10, weight=500, fill="#8A919A")
s.text(250, y + 24, label, size=13, weight=600, anchor="middle")
s.text(250, 575, "완료 결과를 main session으로 반환", size=11, weight=450, fill=MUTED, anchor="middle", italic=True)
s.box(520, 245, 370, 160, "BACKGROUND BASH", subtitle="tail -f -", role="<<streaming>>", stroke="#555B63", fill="#FCFCFC")
s.text(705, 375, "새 stdout 라인을 이벤트로 전달", size=11, weight=450, fill=MUTED, anchor="middle", italic=True)
s.box(520, 435, 370, 160, "BACKGROUND BASH", subtitle="until <cond>; do sleep N; done", role="<<polling>>", stroke="#555B63", fill="#FCFCFC")
s.text(705, 565, "조건 충족까지 주기적으로 확인", size=11, weight=450, fill=MUTED, anchor="middle", italic=True)
s.box(1010, 245, 340, 350, "MONITOR", subtitle="stdout 수신 · 이벤트 발생 시 알림", role="<<built-in tool>>", stroke="#555B63", fill="#FCFCFC")
s.text(1180, 565, "main session으로 notification", size=11, weight=450, fill=MUTED, anchor="middle", italic=True)
s.polyline([(260, 143), (260, 185), (250, 185), (250, 245)], stroke=INK, sw=1.8, marker="arrow-ink")
s.label(210, 178, "dispatch", size=11, bg=WHITE, stroke="#C5C9CE")
s.polyline([(290, 245), (290, 205), (300, 205), (300, 143)], stroke=MUTED, sw=1.6, dash="5 4", marker="arrow-muted")
s.label(342, 203, "완료 결과 × N", size=11, fill=MUTED, bg=WHITE, stroke="#C5C9CE")
s.line(705, 143, 705, 245, stroke=INK, sw=1.8, marker="arrow-ink")
s.label(705, 198, "spawn × 2", size=11, bg=WHITE, stroke="#C5C9CE")
s.polyline([(1180, 143), (1180, 190), (1180, 190), (1180, 245)], stroke=INK, sw=1.8, marker="arrow-ink")
s.label(1135, 178, "subscribe", size=11, bg=WHITE, stroke="#C5C9CE")
s.polyline([(1220, 245), (1220, 205), (1220, 205), (1220, 143)], stroke=MUTED, sw=1.6, dash="5 4", marker="arrow-muted")
s.label(1275, 203, "notification", size=11, fill=MUTED, bg=WHITE, stroke="#C5C9CE")
s.line(890, 320, 1010, 320, stroke=INK, sw=1.7, marker="arrow-ink")
s.label(950, 310, "stdout", size=11, bg=WHITE, stroke="#C5C9CE")
s.line(890, 510, 1010, 510, stroke=INK, sw=1.7, marker="arrow-ink")
s.label(950, 500, "stdout", size=11, bg=WHITE, stroke="#C5C9CE")
def build_query_fanout(s: Svg) -> None:
s.person(55, 245, stroke="#4B5563", scale=1.1)
s.rect(45, 35, 475, 105, fill=WHITE, stroke="#6B7280", sw=1.5, rx=16)
s.polygon([(82, 140), (105, 140), (91, 185)], fill=WHITE, stroke="#6B7280", sw=1.3)
s.text(78, 69, "사용자 범위 쿼리", size=13, weight=500, fill=MUTED)
s.text(78, 97, 'rate(http_requests_total{container="search-api"}[5m])', size=16, weight=600, family=MONO)
s.text(78, 122, "2026/06/22 15:00 15:10", size=12, weight=450, fill=MUTED)
s.line(92, 245, 260, 245, stroke=INK, sw=2, marker="arrow-ink")
s.box(260, 205, 180, 80, "vmselect", fill=GREEN_LIGHT, stroke=GREEN, sw=2, title_fill="#087A4E")
s.box(300, 325, 100, 44, "query parser", fill=WHITE, stroke="#A7ADB5", sw=1.2, title_size=12)
s.line(350, 285, 350, 325, stroke="#A7ADB5", sw=1.3)
s.line(440, 245, 690, 245, stroke=INK, sw=2)
s.label(565, 232, "FILTERS & TIMERANGE(start, end)", size=12, bg=WHITE)
s.circle(690, 245, 4, fill=INK)
y_positions = [35, 205, 375]
shard_names = ["vmstorage A", "vmstorage B", "vmstorage C"]
for y, shard_name in zip(y_positions, shard_names):
s.rect(820, y, 550, 135, fill=BLUE_LIGHT, stroke="#2585C7", sw=1.8, rx=10)
s.database(845, y + 26, 58, 55, "", fill="#0B659D", stroke="#0B659D")
s.box(930, y + 35, 105, 55, "IndexDB", fill="#BFE7FF", stroke="#1778B7", sw=1.5, title_size=15)
s.box(1080, y + 35, 100, 55, "TSID", fill="#BFE7FF", stroke="#1778B7", sw=1.5, title_size=15)
s.box(1230, y + 35, 100, 55, "Data", fill="#BFE7FF", stroke="#1778B7", sw=1.5, title_size=15)
s.line(903, y + 62, 930, y + 62, stroke=INK, sw=1.5, marker="arrow-ink")
s.line(1035, y + 62, 1080, y + 62, stroke=INK, sw=1.5, marker="arrow-ink")
s.line(1180, y + 62, 1230, y + 62, stroke=INK, sw=1.5, marker="arrow-ink")
s.text(830, y + 119, shard_name, size=13, weight=600, fill="#174D6B")
target_y = y + 67
s.line(690, 245, 820, target_y, stroke=INK, sw=1.7, marker="arrow-ink")
s.rect(220, 415, 380, 110, fill="#FAFAFA", stroke="#E3E5E8", sw=1, rx=6)
s.text(245, 445, "FUNC", size=10, weight=700, fill="#858B94")
s.text(245, 475, "rate", size=13, weight=600)
s.text(355, 445, "FILTERS", size=10, weight=700, fill="#858B94")
s.multiline(355, 475, ['__name__="http_requests_total"', 'container="search-api"'], size=11, family=MONO, line_height=1.45)
s.text(545, 445, "WINDOW", size=10, weight=700, fill="#858B94")
s.text(545, 475, "5m", size=13, weight=600)
def build_timeline(s: Svg) -> None:
y = 180
s.line(25, y, 1400, y, stroke="#34383D", sw=1.8, marker="arrow-ink")
points = [
(60, "T = 0", "1970-01-01", "Unix Epoch (기준점)", "#FFFFFF", "#34383D"),
(260, "+372d", "1971-01-08", "", "#FFFFFF", "#34383D"),
(450, "+744d", "1972-01-15", "", "#FFFFFF", "#34383D"),
(840, "n=55", "2026-01-07", "직전 로테이션", "#CF6274", "#CF6274"),
(1070, "● 현재", "", "", "#55B56A", "#55B56A"),
(1320, "n=56", "2027-01-14", "다음 로테이션 (예상)", "#5A82E4", "#5A82E4"),
]
for x, upper, date, note, fill, stroke in points:
if fill == WHITE:
s.circle(x, y, 10, fill=WHITE, stroke=stroke, sw=1.7)
else:
s.circle(x, y, 12, fill=fill, stroke=fill, sw=1.5)
s.line(x, 110, x, 250, stroke=stroke, sw=2)
s.text(x, 148 if fill == WHITE else 95, upper, size=14, weight=700, fill=stroke, anchor="middle")
if date:
s.text(x, 240, date, size=13, weight=700 if fill != WHITE else 500, fill=stroke, anchor="middle")
if note:
s.text(x, 267, note, size=11, weight=450, fill=MUTED, anchor="middle")
s.text(650, 165, "…", size=24, weight=600, fill="#8A9097", anchor="middle")
s.polyline([(840, 350), (840, 367), (1320, 367), (1320, 350)], stroke="#777D85", sw=1.2)
s.text(1080, 405, "372일 주기 (1년 + 31일 × 12 = 372일)", size=12, weight=500, fill="#4B5057", anchor="middle")
def build_reconciliation(s: Svg) -> None:
blue = "#1F75FF"
white = WHITE
s.document(70, 145, 320, 390, stroke=blue, fill=BLACK, sw=4, fold=70)
s.multiline(
110,
255,
[
"kind: VM",
"spec:",
" vCPU: 2",
" memory: 8GB",
" process:",
" - dbaas-agent",
"status:",
" dbaas-agent: ok",
],
size=21,
weight=650,
fill=white,
family=MONO,
line_height=1.28,
)
s.text(230, 585, "VM Custom Resource", size=22, weight=650, fill=white, anchor="middle")
s.server(620, 235, 240, 250, stroke=blue, fill=BLACK, sw=4, rows=3, accent=blue)
s.text(740, 535, "VM Operator", size=24, weight=650, fill=white, anchor="middle")
s.rect(1120, 170, 245, 370, fill=BLACK, stroke=blue, sw=4)
s.polygon([(1200, 260), (1285, 260), (1305, 290), (1180, 290)], fill=blue)
s.rect(1190, 290, 105, 95, fill=blue)
s.text(1242, 347, "dbaas-agent", size=17, weight=650, fill=white, anchor="middle")
s.text(1242, 590, "VM", size=22, weight=650, fill=white, anchor="middle")
s.line(620, 360, 390, 360, stroke=blue, sw=4, marker="arrow-blue")
s.label(510, 350, "watch", size=19, fill=white, bg=BLACK, weight=650)
s.line(860, 360, 1000, 360, stroke=blue, sw=4)
s.label(925, 345, "create VM", size=18, fill=white, bg=BLACK, weight=650)
s.x_mark(1025, 360, 48, stroke="#FF3B45", sw=11)
s.line(1080, 360, 1120, 360, stroke=blue, sw=4, marker="arrow-blue")
s.text(1025, 255, "실패 시 recreate", size=19, weight=650, fill="#FF3B45", anchor="middle")
s.polyline([(1240, 540), (1240, 640), (740, 640), (740, 485)], stroke=blue, sw=2.5, dash="8 7", marker="arrow-blue")
s.label(990, 628, "observe status", size=15, fill=white, bg=BLACK, weight=600)
def _stack_documents(s: Svg, x: float, y: float, w: float, h: float, count: int, *, stroke: str, fill: str) -> None:
for offset in reversed(range(count)):
s.document(x + offset * 22, y - offset * 10, w, h, stroke=stroke, fill=fill, sw=3.2, fold=50)
def build_resource_architecture(s: Svg) -> None:
blue = "#1F75FF"
white = WHITE
s.person(85, 330, stroke=blue, sw=4, scale=1.7)
s.text(85, 455, "User", size=20, weight=650, fill=white, anchor="middle")
s.document(260, 175, 300, 360, stroke=blue, fill=BLACK, sw=4, fold=65)
s.multiline(
295,
245,
[
"kind: DB Service",
"spec:",
" vmCount: 3",
" vmConfig:",
" vCPU: 2",
" memory: 8GB",
" dbConfig:",
" maxclient: 3000",
" timeout: 5s",
],
size=18,
weight=600,
fill=white,
family=MONO,
line_height=1.35,
)
s.server(635, 545, 230, 190, stroke=blue, fill=BLACK, sw=4, rows=3, accent=blue)
s.text(750, 775, "DBaaS Manager", size=23, weight=650, fill=white, anchor="middle")
_stack_documents(s, 950, 130, 210, 215, 3, stroke=blue, fill=BLACK)
s.multiline(985, 205, ["2 vCPU", "8 GB RAM", "…"], size=18, weight=600, fill=white, family=MONO, line_height=1.25)
s.text(1070, 400, "VM Custom Resource", size=18, weight=600, fill=white, anchor="middle")
_stack_documents(s, 950, 455, 210, 215, 3, stroke=blue, fill=BLACK)
s.multiline(980, 535, ["maxclient: 3000", "timeout: 5s", "…"], size=16, weight=600, fill=white, family=MONO, line_height=1.35)
s.text(1070, 735, "DB Instance Custom Resource", size=17, weight=600, fill=white, anchor="middle")
s.server(1325, 115, 175, 145, stroke=blue, fill=BLACK, sw=4, rows=3, accent=blue)
s.text(1412, 300, "VM Operator", size=18, weight=650, fill=white, anchor="middle")
s.rect(1290, 400, 235, 330, fill=BLACK, stroke=blue, sw=4)
s.polygon([(1350, 455), (1435, 455), (1450, 482), (1335, 482)], fill=blue)
s.rect(1340, 482, 105, 90, fill=blue)
s.text(1392, 535, "dbaas-agent", size=15, weight=650, fill=white, anchor="middle")
s.text(1392, 620, "DB command", size=16, weight=600, fill=white, anchor="middle")
s.database(1347, 640, 90, 62, "DB", fill=blue, stroke=blue, title_fill=white)
s.text(1407, 770, "VM", size=18, weight=650, fill=white, anchor="middle")
s.line(140, 365, 260, 365, stroke=blue, sw=4, marker="arrow-blue")
s.label(200, 352, "create", size=17, fill=white, bg=BLACK)
s.polyline([(635, 635), (525, 635), (525, 535)], stroke=blue, sw=4, marker="arrow-blue")
s.label(565, 620, "watch", size=17, fill=white, bg=BLACK)
s.polyline([(865, 615), (900, 615), (900, 260), (950, 260)], stroke=blue, sw=4, marker="arrow-blue")
s.label(900, 435, "create", size=17, fill=white, bg=BLACK)
s.line(865, 650, 950, 575, stroke=blue, sw=4, marker="arrow-blue")
s.label(900, 625, "create", size=17, fill=white, bg=BLACK)
s.polyline([(1325, 190), (1215, 190), (1215, 250), (1160, 250)], stroke=blue, sw=4, marker="arrow-blue")
s.label(1240, 175, "watch", size=16, fill=white, bg=BLACK)
s.line(1412, 260, 1412, 400, stroke=blue, sw=4, marker="arrow-blue")
s.label(1460, 350, "create VM", size=16, fill=white, bg=BLACK)
s.polyline([(1160, 560), (1245, 560), (1245, 527), (1340, 527)], stroke=blue, sw=3.2, marker="arrow-blue")
s.label(1245, 548, "config", size=15, fill=white, bg=BLACK)
s.line(1392, 572, 1392, 640, stroke=blue, sw=3.2, marker="arrow-blue")
def _mini_page(s: Svg, x: float, y: float, title: str) -> None:
s.rect(x, y, 220, 120, fill="#FBFBF8", stroke="#5D6268", sw=1.5, rx=3)
s.text(x + 110, y + 24, title, size=14, weight=700, anchor="middle")
for row in range(3):
s.rect(x + 20, y + 40 + row * 23, 36, 16, fill="#EFEFE9", stroke="#8A8F95", sw=0.8, rx=2)
s.line(x + 68, y + 47 + row * 23, x + 190, y + 47 + row * 23, stroke="#858A90", sw=1)
s.line(x + 68, y + 54 + row * 23, x + 155, y + 54 + row * 23, stroke="#B0B4B8", sw=0.9)
def build_localization_pipeline(s: Svg) -> None:
s.group_box(35, 45, 665, 545, "Backend BFF Handling · 사용자 요청 및 서비스 레이어", stroke="#C9BFA5", fill="#FFFDF7", dash=None, label_fill="#424242")
s.group_box(745, 45, 660, 545, "Translation Pipeline · 데이터 번역 및 적재 레이어", stroke="#C9BFA5", fill="#FFFDF7", dash=None, label_fill="#424242")
s.phone(70, 220, 90, 175, stroke="#4A4A47", fill="#FFFDF7")
s.text(115, 305, "배민앱", size=15, weight=700, anchor="middle")
s.text(115, 425, "Accept-Language 헤더", size=11, weight=500, fill=MUTED, anchor="middle")
_mini_page(s, 245, 120, "목록 / 상세")
_mini_page(s, 245, 350, "장바구니 / 주문")
s.text(355, 260, "다국어 정책 화면", size=12, weight=600, anchor="middle")
s.text(355, 490, "다국어 정책 화면", size=12, weight=600, anchor="middle")
s.document(555, 205, 110, 220, stroke="#4A4A47", fill="#FFFDF7", sw=2.2, fold=24)
s.text(610, 325, "FDH", size=25, weight=700, anchor="middle")
s.line(160, 275, 245, 205, stroke="#4A4A47", sw=2.2, marker="arrow-ink")
s.line(160, 340, 245, 410, stroke="#4A4A47", sw=2.2, marker="arrow-ink")
s.line(465, 180, 555, 260, stroke="#4A4A47", sw=2.2, marker="arrow-ink")
s.label(505, 195, "lang param", size=11, bg="#FFFDF7")
s.line(465, 410, 555, 365, stroke="#4A4A47", sw=2.2, marker="arrow-ink")
s.label(505, 392, "lang param", size=11, bg="#FFFDF7")
s.box(820, 110, 150, 65, "가게 / 메뉴", fill="#FFFDF7", stroke="#4A4A47", sw=1.5, title_size=15)
s.box(985, 225, 170, 82, "FDH Worker", fill="#FFFDF7", stroke="#4A4A47", sw=1.7, title_size=16)
s.box(1210, 235, 135, 62, "Queue", fill="#FFFDF7", stroke="#4A4A47", sw=1.6, title_size=16)
s.box(985, 410, 170, 82, "LLM Translator", fill="#FFFDF7", stroke="#4A4A47", sw=1.7, title_size=16)
s.line(895, 175, 1050, 225, stroke="#4A4A47", sw=2, marker="arrow-ink")
s.label(970, 188, "이벤트", size=11, bg="#FFFDF7")
s.path("M 1155 260 C 1180 260, 1185 260, 1210 260", stroke="#4A4A47", sw=2, marker="arrow-ink")
s.path("M 1277 297 C 1295 360, 1230 445, 1155 451", stroke="#4A4A47", sw=2, marker="arrow-ink")
s.path("M 985 451 C 900 451, 900 320, 985 267", stroke="#4A4A47", sw=2, marker="arrow-ink")
s.text(1070, 360, "LLM 기반 자동 번역", size=13, weight=600, anchor="middle")
s.line(985, 267, 665, 315, stroke="#4A4A47", sw=2.2, marker="arrow-ink")
s.label(820, 285, "다국어 적재", size=12, bg="#FFFDF7")
def build_sequence(s: Svg) -> None:
participants = [
(120, "Client"),
(390, "Checkout API"),
(680, "Payment Provider"),
(970, "Order DB"),
(1240, "Event Bus"),
]
for x, name in participants:
s.box(x - 85, 35, 170, 58, name, fill="#FAFAFA", stroke="#535A63", sw=1.4, title_size=15)
s.line(x, 93, x, 670, stroke="#B3B8BF", sw=1.2, dash="6 6")
s.rect(380, 135, 20, 405, fill="#EAF3FF", stroke=BLUE, sw=1.3, rx=2)
s.rect(670, 205, 20, 105, fill="#F3F3F3", stroke="#6D737B", sw=1.2, rx=2)
def msg(y: float, x1: float, x2: float, label: str, number: int, *, color: str = INK, dashed: bool = False) -> None:
marker = "arrow-blue" if color == BLUE else "arrow-ink" if color == INK else "arrow-purple"
s.line(x1, y, x2, y, stroke=color, sw=1.8, dash="6 5" if dashed else None, marker=marker)
s.circle(35, y, 14, fill=color)
s.text(35, y + 5, str(number), size=11, weight=700, fill=WHITE, anchor="middle")
s.label((x1 + x2) / 2, y - 10, label, size=12, fill=color, bg=WHITE)
msg(150, 120, 380, "POST /payments", 1, color=BLUE)
msg(220, 400, 670, "authorize(payment key)", 2, color=INK)
msg(290, 670, 400, "approved", 3, color=INK, dashed=True)
msg(380, 400, 970, "UPDATE status = PAID", 4, color=BLUE)
msg(470, 400, 1240, "publish payment.approved", 5, color=PURPLE)
msg(590, 380, 120, "201 Created", 6, color=INK, dashed=True)
s.rect(955, 360, 30, 55, fill="#EAF3FF", stroke=BLUE, sw=1.2, rx=2)
s.rect(1225, 450, 30, 55, fill="#F1EDFF", stroke=PURPLE, sw=1.2, rx=2)
def build_ports_adapters(s: Svg) -> None:
# central application core
hex_points = [(540, 120), (820, 120), (920, 325), (820, 530), (540, 530), (440, 325)]
s.polygon(hex_points, fill="#F1F6FF", stroke=BLUE, sw=2.3)
s.text(680, 285, "Application Core", size=19, weight=700, fill=BLUE_DARK, anchor="middle")
s.text(680, 315, "use cases + domain model", size=13, weight=500, fill=MUTED, anchor="middle")
s.line(555, 350, 805, 350, stroke="#A7BDE0", sw=1.1)
s.text(680, 380, "ports", size=12, weight=600, fill=MUTED, anchor="middle")
inbound = [(70, 115, "Web Adapter", "REST · inbound"), (70, 270, "Batch Adapter", "scheduled job"), (70, 425, "Admin CLI", "command")]
outbound = [(1070, 115, "Payment Client", "HTTP"), (1070, 270, "Order Repository", "JPA"), (1070, 425, "Event Publisher", "Kafka")]
port_y = [180, 325, 470]
for (x, y, title, subtitle), py in zip(inbound, port_y):
s.box(x, y, 250, 85, title, subtitle=subtitle, fill="#FAFAFA", stroke="#59616B", sw=1.4)
s.circle(440, py, 9, fill=WHITE, stroke=BLUE, sw=2)
s.line(320, y + 42, 431, py, stroke=BLUE, sw=1.9, marker="arrow-blue")
s.label(376, py - 10, "inbound port", size=11, fill=BLUE_DARK, bg=WHITE)
for (x, y, title, subtitle), py in zip(outbound, port_y):
s.box(x, y, 250, 85, title, subtitle=subtitle, fill="#FAFAFA", stroke="#59616B", sw=1.4)
s.circle(920, py, 9, fill=WHITE, stroke=BLUE, sw=2)
s.line(929, py, 1070, y + 42, stroke=BLUE, sw=1.9, marker="arrow-blue")
s.label(997, py - 10, "outbound port", size=11, fill=BLUE_DARK, bg=WHITE)
s.database(1330, 276, 90, 72, "DB", fill=WHITE, stroke="#59616B")
s.line(1320, 312, 1330, 312, stroke="#59616B", sw=1.6, marker="arrow-ink")
s.label(1350, 265, "JDBC", size=11, fill=MUTED, bg=WHITE)
EXAMPLES: list[Example] = [
Example(
"01-component-flow",
"payment-event-flow.svg",
"Component flow",
"component_flow",
"결제 승인 이후 상태 저장과 이벤트 발행 순서는 무엇인가?",
"클라이언트, 인증 게이트웨이, 체크아웃 API, 주문 DB, 이벤트 버스, 결제 제공자 간의 요청·응답·상태 저장·이벤트 발행 관계.",
1440,
620,
build_payment_flow,
),
Example(
"02-orchestrator-workers",
"mission-workers.svg",
"Orchestrator and workers",
"orchestrator_workers",
"메인 세션이 서브 에이전트와 백그라운드 프로세스를 어떻게 조정하는가?",
"메인 세션이 작업을 fan-out하고 스트리밍·폴링 프로세스를 실행하며 monitor의 알림을 구독하는 구조.",
1440,
680,
build_orchestrator_workers,
),
Example(
"03-query-fanout",
"metrics-query-fanout.svg",
"Query fan-out",
"query_fanout",
"범위 쿼리가 어떤 저장소 샤드로 분산되는가?",
"사용자 쿼리를 파싱한 뒤 시간 범위와 필터를 기준으로 세 개의 저장소 샤드에 fan-out하는 데이터 조회 구조.",
1440,
560,
build_query_fanout,
),
Example(
"04-timeline",
"retention-cycle.svg",
"Timeline",
"timeline",
"기준일과 현재·다음 로테이션의 시간 간격은 어떻게 되는가?",
"Unix Epoch 기준점과 372일 주기의 이전·현재·다음 로테이션을 나타내는 시간축.",
1440,
430,
build_timeline,
),
Example(
"05-reconciliation-loop",
"declarative-vm.svg",
"Declarative reconciliation",
"reconciliation_loop",
"선언 상태와 실제 VM 상태가 다를 때 컨트롤러는 무엇을 하는가?",
"VM Custom Resource를 감시하는 VM Operator가 VM을 생성하고 실패 시 재생성하며 상태를 관찰하는 조정 루프.",
1440,
720,
build_reconciliation,
),
Example(
"06-resource-architecture",
"dbaas-controller.svg",
"Resource controller architecture",
"resource_controller",
"DB 서비스 명세가 실제 VM과 DB 인스턴스로 어떻게 구체화되는가?",
"DB Service Custom Resource, DBaaS Manager, VM/DB Instance Custom Resource, VM Operator, 런타임 VM 간의 생성·감시 관계.",
1600,
820,
build_resource_architecture,
),
Example(
"07-localization-pipeline",
"localization-pipeline.svg",
"Localization pipeline",
"two_zone_pipeline",
"사용자 언어 처리와 번역 데이터 적재는 어느 경계에서 분리되는가?",
"BFF 사용자 요청 처리와 worker·queue·translator 기반 번역 적재 파이프라인을 두 경계로 나눈 구조.",
1440,
650,
build_localization_pipeline,
),
Example(
"08-sequence",
"payment-approval-sequence.svg",
"Sequence",
"sequence",
"결제 승인·상태 저장·이벤트 발행은 어떤 시간 순서로 수행되는가?",
"클라이언트부터 체크아웃 API, 결제 제공자, 주문 DB, 이벤트 버스까지의 결제 승인 시퀀스.",
1440,
720,
build_sequence,
),
Example(
"09-ports-adapters",
"order-ports-adapters.svg",
"Ports and adapters",
"ports_adapters",
"어댑터의 의존성은 어떤 포트를 통해 애플리케이션 코어로 향하는가?",
"웹·배치·CLI 인바운드 어댑터와 결제·저장소·이벤트 아웃바운드 어댑터가 포트를 통해 애플리케이션 코어에 연결되는 구조.",
1440,
650,
build_ports_adapters,
),
]
STYLE_CONTRACT = """# Diagram-only style contract
이 예제 세트는 **문서 안에 삽입되는 다이어그램 자체**만 평가합니다. 본문의 제목·설명·결론은 문서가 담당하고, SVG 캔버스는 관계를 읽는 데 필요한 요소만 포함합니다.
## 캔버스에 허용되는 것
- 노드와 노드 내부의 기술 식별자
- 시스템·도메인·프로세스 경계와 경계 이름
- 연결선, 방향, 프로토콜·이벤트·명령·상태 레이블
- 시퀀스 번호, 타임라인 기준점, 실패·현재 상태처럼 의미가 있는 표시
- 다이어그램을 해독하는 데 반드시 필요한 짧은 주석
## 캔버스에서 금지되는 것
- 문서 제목을 반복하는 큰 헤드라인과 부제
- 하단 결론 띠, 슬로건, 핵심 메시지 카드
- 패턴 번호, 생성기 이름, 워터마크, decorative footer
- 그라디언트, glow, drop shadow, glass effect
- 의미 없이 배치된 metric card, badge, sparkline
- 모든 노드를 서로 다른 색으로 칠하는 장식성 컬러 코딩
- 관계보다 디자인을 먼저 보이게 만드는 과도한 둥근 모서리와 아이콘
## 기본 시각 예산
- 기본은 회색조 + 주 강조색 1개
- 오류·성공처럼 도메인 의미가 있을 때만 상태색 추가
- 선 굵기 1.22.3px, 강조 선도 4px 이하
- 박스 radius 010px; pill은 상태 토큰이나 작은 edge label에만 제한
- 그림자와 그라디언트 0개
- 본문 설명은 SVG 밖의 Markdown 문단에 둔다
## 레이아웃 원칙
1. 독자의 질문에 맞는 관습적인 다이어그램 유형을 먼저 선택한다.
2. 연결선이 교차하지 않도록 읽기 방향을 한 축으로 고정한다.
3. 경계와 그룹은 배경 장식이 아니라 소유권·실행·배포 범위를 나타낼 때만 쓴다.
4. 같은 역할은 같은 도형으로 표현한다.
5. 화살표 레이블은 동사·이벤트·프로토콜·상태 변화로 작성한다.
6. 문서의 설명을 그림 안에서 다시 서술하지 않는다.
"""
README_TEMPLATE = """# TechViz examples — diagram-only fixtures
이 폴더는 기술 블로그와 사내 문서에 바로 삽입할 수 있는 **실용적 다이어그램 품질 기준**입니다. 0.2.0의 편집형 카드·헤드라인·하단 메시지 띠를 제거하고, 관계를 이해하는 데 필요한 요소만 SVG 캔버스에 남겼습니다.
![gallery](gallery/gallery.png)
| Fixture | 구조 | SVG |
|---|---|---|
{rows}
## 핵심 변경
- 예제 SVG에는 전역 제목·부제·footer·결론 띠가 없습니다.
- 그라디언트, 그림자, glow, 장식용 badge를 사용하지 않습니다.
- 색은 관계·상태를 구분할 때만 사용합니다.
- `composition.json`은 그림의 논리 구조와 금지 요소를 함께 기록합니다.
- `STYLE_CONTRACT.md`가 향후 compositor와 모델 프롬프트의 기본 시각 계약입니다.
`assets/`, `docs/`, `work/`는 기존 런타임 렌더러의 회귀 테스트 자료이므로 그대로 유지합니다. 번호 디렉터리는 다음 렌더러가 목표로 삼을 품질 fixture입니다.
## 재생성
```bash
python examples/build_examples.py
python examples/validate_examples.py
```
빌드 스크립트는 SVG, PNG preview, gallery, `manifest.json`, `CHECKSUMS.sha256`를 결정적으로 다시 생성합니다. 검증 스크립트는 title/footer/effect 금지 규칙과 접근성 메타데이터를 검사합니다.
"""
RENDERER_GAPS = """# Renderer gaps for diagram-only output
현재 런타임 렌더러가 개선해야 할 핵심은 시각 효과가 아니라 **다이어그램 문법 선택과 연결선 제어**입니다.
## 필요한 기능
- `diagram_type`: component-flow, orchestrator-workers, query-fanout, timeline, reconciliation-loop, resource-controller, sequence, ports-adapters
- `boundary`: 시스템·프로세스·도메인·저장소 샤드 경계
- `node_shape`: box, database, document, server, actor, phone, port
- `edge_route`: straight, orthogonal, authored-waypoints, return-path
- `edge_semantics`: command, response, event, watch, create, status, failure
- `timeline`: 기준점, 날짜, 기간 bracket
- `sequence`: lifeline, activation, numbered message, dashed response
- `theme`: light-neutral, dark-technical, sketch-neutral
## 불필요한 기능
아래 항목은 기본 렌더러 목표가 아닙니다.
- hero title 영역
- takeaway footer band
- 카드형 metric strip
- glow·gradient·drop shadow
- decorative pattern background
- 브랜드 포스터형 아이콘 세트
## 품질 게이트
1. visible SVG text가 노드·경계·연결·상태·시간 의미 중 하나에 귀속되어야 한다.
2. 전역 제목·부제·footer 문자열이 SVG에 없어야 한다.
3. 노드와 무관한 장식 도형이 없어야 한다.
4. 주요 연결선이 노드를 관통하거나 불필요하게 교차하지 않아야 한다.
5. 같은 역할의 노드는 같은 도형·stroke·fill 규칙을 사용해야 한다.
6. 색을 제거해도 구조를 읽을 수 있어야 한다.
"""
DESIGN_DIRECTION = """# Design direction
## 문제 진단
0.2.0 예제는 SVG 기능을 적극 사용했지만, 문서 다이어그램보다 편집형 인포그래픽에 가까웠습니다. 큰 제목, 부제, focal glow, metric card, 하단 결론 띠가 그림의 논리보다 먼저 보였습니다. 이는 SVG의 한계가 아니라 composition 정책의 문제입니다.
## 목표
- 기술 블로그 본문 사이에 자연스럽게 들어가는 그림
- 별도의 설명 없이도 화살표와 경계의 논리가 읽히는 그림
- 다이어그램 밖의 본문과 역할이 겹치지 않는 그림
- 작성 도구가 draw.io, Excalidraw, Figma, PowerPoint 중 무엇이든 동일하게 적용할 수 있는 문법
## 적용한 방향
1. SVG 캔버스를 내용 경계에 가깝게 자르고 빈 장식 영역을 없앴습니다.
2. 제목·부제·footer·takeaway를 제거했습니다.
3. grayscale을 기본으로 두고 강조색을 제한했습니다.
4. 시스템 구조에는 box·document·server·database 같은 익숙한 도형을 사용했습니다.
5. 시간 정보는 timeline, 호출 순서는 sequence, 선언 상태는 reconciliation loop처럼 관습적인 문법으로 분리했습니다.
6. `composition.json`에 `forbidden_visible_elements`를 추가해 모델이 포스터형 장식을 생성하지 못하도록 했습니다.
"""
def write_json(path: Path, data: object) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def cleanup_numbered_dirs() -> None:
for child in ROOT.iterdir():
if child.is_dir() and len(child.name) >= 3 and child.name[:2].isdigit() and child.name[2] == "-":
shutil.rmtree(child)
def write_example_metadata(example: Example, directory: Path) -> None:
composition = {
"id": Path(example.filename).stem,
"visual_grammar": example.profile,
"reader_question": example.question,
"diagram_only": True,
"visible_text_scope": [
"node labels",
"boundary labels",
"edge labels",
"state/time annotations required to decode the relation",
],
"forbidden_visible_elements": [
"global title",
"subtitle paragraph",
"footer",
"takeaway band",
"pattern number",
"generator watermark",
"decorative metric card",
"gradient",
"drop shadow",
"glow",
],
"palette_policy": "grayscale plus one primary accent; status colors only when semantic",
"required_renderer_capabilities": {
"01-component-flow": ["system boundary", "database shape", "parallel state writes", "return path"],
"02-orchestrator-workers": ["hierarchy", "worker group", "bidirectional control", "stdout routes"],
"03-query-fanout": ["query annotation", "fan-out junction", "repeated shard group"],
"04-timeline": ["time axis", "dated markers", "period bracket"],
"05-reconciliation-loop": ["document shape", "controller", "failure marker", "status feedback"],
"06-resource-architecture": ["custom resource documents", "controller graph", "runtime boundary"],
"07-localization-pipeline": ["two-zone boundary", "pipeline loop", "sketch-neutral theme"],
"08-sequence": ["lifeline", "activation", "numbered messages", "dashed response"],
"09-ports-adapters": ["hexagonal core", "port sockets", "inbound/outbound adapters"],
}[example.folder],
}
write_json(directory / "composition.json", composition)
(directory / "context.md").write_text(
f"# Context\n\n독자가 확인해야 할 질문: **{example.question}**\n\n이 fixture는 문서 본문이 이미 문제와 결론을 설명한다고 가정한다. 따라서 SVG에는 다이어그램 관계를 해독하는 데 필요한 기술 레이블만 둔다.\n",
encoding="utf-8",
)
(directory / "alt.md").write_text(
f"# 대체 설명\n\n{example.desc}\n\n전역 제목·부제·footer는 보이는 캔버스에 포함하지 않는다. 접근성용 SVG `<title>`과 `<desc>`는 보이지 않는 메타데이터로 유지한다.\n",
encoding="utf-8",
)
def render_examples() -> list[dict[str, object]]:
cleanup_numbered_dirs()
records: list[dict[str, object]] = []
for example in EXAMPLES:
directory = ROOT / example.folder
directory.mkdir(parents=True, exist_ok=True)
svg_path = directory / example.filename
preview_path = directory / example.filename.replace(".svg", ".preview.png")
doc = Svg(example.width, example.height, title=example.name, desc=example.desc, background=BLACK if example.folder in {"05-reconciliation-loop", "06-resource-architecture"} else WHITE)
example.build(doc)
svg_path.write_text(doc.render(), encoding="utf-8")
cairosvg.svg2png(url=str(svg_path), write_to=str(preview_path), output_width=example.width, output_height=example.height)
write_example_metadata(example, directory)
records.append(
{
"folder": example.folder,
"name": example.name,
"profile": example.profile,
"svg": str(svg_path.relative_to(ROOT)),
"preview": str(preview_path.relative_to(ROOT)),
"width": example.width,
"height": example.height,
}
)
return records
def build_gallery(records: list[dict[str, object]]) -> None:
gallery_dir = ROOT / "gallery"
gallery_dir.mkdir(exist_ok=True)
cols = 3
thumb_w = 500
thumb_h = 300
label_h = 46
margin = 28
rows = math.ceil(len(records) / cols)
canvas = Image.new("RGB", (margin + cols * (thumb_w + margin), margin + rows * (thumb_h + label_h + margin)), "#EEF1F5")
draw = ImageDraw.Draw(canvas)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 19)
number_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 22)
except OSError:
font = ImageFont.load_default()
number_font = font
for index, record in enumerate(records):
row, col = divmod(index, cols)
x = margin + col * (thumb_w + margin)
y = margin + row * (thumb_h + label_h + margin)
image = Image.open(ROOT / str(record["preview"])).convert("RGB")
image.thumbnail((thumb_w, thumb_h), Image.Resampling.LANCZOS)
tile = Image.new("RGB", (thumb_w, thumb_h), "white")
tile.paste(image, ((thumb_w - image.width) // 2, (thumb_h - image.height) // 2))
draw.rectangle((x - 1, y - 1, x + thumb_w, y + thumb_h), outline="#C7CDD5", width=1)
canvas.paste(tile, (x, y))
draw.text((x, y + thumb_h + 12), f"{index + 1:02d}", font=number_font, fill="#2E64D4")
draw.text((x + 44, y + thumb_h + 14), str(record["name"]), font=font, fill="#313842")
gallery_path = gallery_dir / "gallery.png"
canvas.save(gallery_path, optimize=True)
cards = []
for record in records:
cards.append(
f'<article><a href="../{record["svg"]}"><img src="../{record["preview"]}" alt="{esc(record["name"])}"></a>'
f'<h2>{esc(record["name"])}</h2><code>{esc(record["profile"])}</code></article>'
)
html_doc = """<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TechViz diagram-only fixtures</title><style>body{font-family:system-ui,sans-serif;margin:28px;background:#eef1f5;color:#24272b}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:22px}article{background:white;border:1px solid #c7cdd5;padding:14px}img{width:100%;height:260px;object-fit:contain;background:white}h2{font-size:17px;margin:10px 0 6px}code{color:#58616c}</style></head><body><main class="grid">""" + "".join(cards) + "</main></body></html>"
(gallery_dir / "index.html").write_text(html_doc, encoding="utf-8")
def write_docs(records: list[dict[str, object]]) -> None:
rows = []
for record in records:
rows.append(
f'| [{record["folder"]}]({record["folder"]}/) | `{record["profile"]}` | [{Path(str(record["svg"])).name}]({record["svg"]}) |'
)
(ROOT / "README.md").write_text(README_TEMPLATE.format(rows="\n".join(rows)), encoding="utf-8")
(ROOT / "STYLE_CONTRACT.md").write_text(STYLE_CONTRACT, encoding="utf-8")
(ROOT / "RENDERER_GAPS.md").write_text(RENDERER_GAPS, encoding="utf-8")
(ROOT / "DESIGN_AUDIT.md").write_text(DESIGN_DIRECTION, encoding="utf-8")
write_json(
ROOT / "design-tokens.json",
{
"version": "0.3.0",
"canvas": {"policy": "content-fitted diagram only", "background": ["white", "black for dark technical source context"]},
"type": {"family": ["Noto Sans CJK KR", "sans-serif"], "node": 15, "edge": 12, "minimum": 11},
"stroke": {"boundary": 1.3, "node": 1.6, "edge": 1.8, "maximum_emphasis": 4},
"radius": {"node_max": 10, "pill_allowed_for": ["compact state token", "edge label only"]},
"color": {"base": "grayscale", "primary_accent_budget": 1, "status_color": "semantic only"},
"effects": {"gradient": False, "shadow": False, "glow": False, "decorative_pattern": False},
"visible_canvas_forbidden": ["global title", "subtitle", "footer", "takeaway band", "watermark", "pattern label"],
},
)
def write_manifest() -> None:
excluded = {"manifest.json", "CHECKSUMS.sha256"}
files = []
for path in sorted(p for p in ROOT.rglob("*") if p.is_file()):
rel = path.relative_to(ROOT).as_posix()
if rel in excluded or rel.startswith("__pycache__/"):
continue
files.append({"path": rel, "bytes": path.stat().st_size, "sha256": sha256(path)})
write_json(ROOT / "manifest.json", {"version": "0.3.0", "files": files})
checksum_text = "\n".join(f'{item["sha256"]} {item["path"]}' for item in files) + "\n"
(ROOT / "CHECKSUMS.sha256").write_text(checksum_text, encoding="utf-8")
def main() -> None:
records = render_examples()
build_gallery(records)
write_docs(records)
write_manifest()
print(f"built {len(records)} diagram-only fixtures")
if __name__ == "__main__":
main()