1300 lines
55 KiB
Python
Executable File
1300 lines
55 KiB
Python
Executable File
#!/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.2–2.3px, 강조 선도 4px 이하
|
||
- 박스 radius 0–10px; pill은 상태 토큰이나 작은 edge label에만 제한
|
||
- 그림자와 그라디언트 0개
|
||
- 본문 설명은 SVG 밖의 Markdown 문단에 둔다
|
||
|
||
## 레이아웃 원칙
|
||
|
||
1. 독자의 질문에 맞는 관습적인 다이어그램 유형을 먼저 선택한다.
|
||
2. 연결선이 교차하지 않도록 읽기 방향을 한 축으로 고정한다.
|
||
3. 경계와 그룹은 배경 장식이 아니라 소유권·실행·배포 범위를 나타낼 때만 쓴다.
|
||
4. 같은 역할은 같은 도형으로 표현한다.
|
||
5. 화살표 레이블은 동사·이벤트·프로토콜·상태 변화로 작성한다.
|
||
6. 문서의 설명을 그림 안에서 다시 서술하지 않는다.
|
||
"""
|
||
|
||
|
||
README_TEMPLATE = """# TechViz examples — diagram-only fixtures
|
||
|
||
이 폴더는 기술 블로그와 사내 문서에 바로 삽입할 수 있는 **실용적 다이어그램 품질 기준**입니다. 0.2.0의 편집형 카드·헤드라인·하단 메시지 띠를 제거하고, 관계를 이해하는 데 필요한 요소만 SVG 캔버스에 남겼습니다.
|
||
|
||

|
||
|
||
| 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()
|