490 lines
23 KiB
Python
490 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""Consulting exhibit SVG library — the signature quantitative/schematic charts.
|
|
|
|
웹조사 결론(docs: 컨설팅 덱 = 논리(Pyramid) + 시그니처 도해)에 따라, Mermaid로 불가능한
|
|
컨설팅 고유 차트를 손제작 인라인 SVG로 생성한다. 디자인 규칙(Zelazny/McKinsey)을 템플릿에 내장:
|
|
- 강조가 필요한 하나의 요소만 accent 색, 나머지는 회색(context).
|
|
- 범례 대신 직접 라벨(direct label). bar는 zero-baseline. gridline은 흐리게/제거.
|
|
Mermaid는 이슈트리/플로우/간트만 가능(일반적 30%). 나머지 시그니처(워터폴·2x2·하비볼·밸류체인·벤치마크)는 여기서.
|
|
|
|
각 함수는 완결된 <svg …>…</svg> 문자열을 반환한다(파일/HTML/Marp에 그대로 embed, git-diffable).
|
|
|
|
Types (render_exhibit dispatcher):
|
|
waterfall, matrix2x2, harvey, valuechain, benchmark, issuetree, process
|
|
"""
|
|
import html
|
|
import math
|
|
|
|
# palette — navy 구조색 + 단일 accent(강조 요소) + pos/neg + 회색 context
|
|
NAVY = "#1f3a5f"
|
|
ACCENT = "#e07b39"
|
|
POS = "#2e8b6f"
|
|
NEG = "#c0504d"
|
|
GRAY = "#b9c2cc"
|
|
GRIDL = "#e7ebf0"
|
|
INK = "#1b2430"
|
|
MUTE = "#5b6472"
|
|
FONT = "font-family:'Segoe UI',Helvetica,Arial,sans-serif"
|
|
|
|
# 렌더 열화(degraded) 신호 — 실물 렌더(d2/mmdc)나 아키타입이 실패해 코드-텍스트 폴백 SVG로
|
|
# 대체됐음을 기계가 감지할 수 있는 마커. 파일/HTML에 embed돼도 보존된다(주석). render_consult가
|
|
# 이 마커로 degraded를 집계해 성공으로 위장하지 않는다.
|
|
DEGRADED_MARKER = "ORGOS-RENDER-DEGRADED"
|
|
|
|
|
|
def _esc(s):
|
|
return html.escape(str(s), quote=True)
|
|
|
|
|
|
def _fmt(v, unit=""):
|
|
if isinstance(v, float):
|
|
s = f"{v:.1f}".rstrip("0").rstrip(".")
|
|
else:
|
|
s = str(v)
|
|
sign = "+" if (isinstance(v, (int, float)) and v > 0 and unit != "" and False) else ""
|
|
return f"{sign}{s}{unit}"
|
|
|
|
|
|
def _wrap(text, max_chars):
|
|
words = str(text).split()
|
|
lines, cur = [], ""
|
|
for w in words:
|
|
if len(cur) + len(w) + 1 <= max_chars or not cur:
|
|
cur = (cur + " " + w).strip()
|
|
else:
|
|
lines.append(cur)
|
|
cur = w
|
|
if cur:
|
|
lines.append(cur)
|
|
return lines or [""]
|
|
|
|
|
|
def _svg(w, h, body):
|
|
return (
|
|
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" '
|
|
f'width="{w}" height="{h}" font-size="15" style="{FONT};max-width:100%;height:auto">'
|
|
f'<rect x="0" y="0" width="{w}" height="{h}" fill="#ffffff"/>{body}</svg>'
|
|
)
|
|
|
|
|
|
def _text(x, y, s, size=15, color=INK, anchor="start", weight="normal"):
|
|
return (
|
|
f'<text x="{x:.1f}" y="{y:.1f}" font-size="{size}" fill="{color}" '
|
|
f'text-anchor="{anchor}" font-weight="{weight}">{_esc(s)}</text>'
|
|
)
|
|
|
|
|
|
def _multiline(x, y, lines, size=13, color=INK, anchor="middle", lh=15):
|
|
out = []
|
|
for i, ln in enumerate(lines):
|
|
out.append(_text(x, y + i * lh, ln, size=size, color=color, anchor=anchor))
|
|
return "".join(out)
|
|
|
|
|
|
def degraded_svg(reason, code="", title="렌더 미가용"):
|
|
"""실물 렌더 실패 시의 폴백 SVG — 코드를 monospace로 보여주되 **열화(degraded)임을 명시**한다.
|
|
① 눈에 보이는 경고 배너(빨강) ② 기계 감지용 SVG 주석 마커(DEGRADED_MARKER:reason).
|
|
render_consult가 이 SVG를 파일/HTML에 embed해도 마커가 보존돼 degraded로 집계된다.
|
|
성공한 렌더는 이 함수를 거치지 않으므로 기존 동작과 구분된다."""
|
|
lines = str(code).strip().split("\n")[:22] if str(code).strip() else []
|
|
h = 64 + len(lines) * 18
|
|
banner = f"⚠ {title} — DEGRADED(실물 렌더 실패 · 폴백 코드 표시)"
|
|
body = [
|
|
f"<!--{DEGRADED_MARKER}:{_esc(reason)}-->",
|
|
f'<rect x="0" y="0" width="820" height="{h}" fill="#fff5f5" stroke="{NEG}" stroke-width="1.5"/>',
|
|
_text(14, 26, banner, size=13, color=NEG, weight="bold"),
|
|
]
|
|
for i, ln in enumerate(lines):
|
|
body.append(
|
|
f'<text x="14" y="{62+i*18}" font-family="monospace" font-size="12" '
|
|
f'fill="{INK}">{_esc(ln)}</text>'
|
|
)
|
|
return _svg(820, h, "".join(body))
|
|
|
|
|
|
# ---------------------------------------------------------------- waterfall
|
|
def waterfall(start, deltas, end, unit="", caption=""):
|
|
"""start=(label,val), deltas=[(label,val)], end=(label,val). 브리지/캐스케이드."""
|
|
W, H = 900, 500
|
|
L, R, T, B = 80, W - 30, 60, H - 80
|
|
plotW, plotH = R - L, B - T
|
|
bars = []
|
|
bars.append({"label": start[0], "bottom": 0, "top": start[1], "color": NAVY, "val": start[1]})
|
|
running = start[1]
|
|
levels = [running]
|
|
for lbl, dv in deltas:
|
|
if dv >= 0:
|
|
b, t, col = running, running + dv, POS
|
|
else:
|
|
b, t, col = running + dv, running, NEG
|
|
bars.append({"label": lbl, "bottom": b, "top": t, "color": col, "val": dv, "delta": True})
|
|
running += dv
|
|
levels.append(running)
|
|
bars.append({"label": end[0], "bottom": 0, "top": end[1], "color": NAVY, "val": end[1]})
|
|
valmax = max([bb["top"] for bb in bars] + [start[1], end[1], running]) * 1.15 or 1
|
|
n = len(bars)
|
|
slot = plotW / n
|
|
bw = slot * 0.6
|
|
|
|
def yv(v):
|
|
return B - (v / valmax) * plotH
|
|
|
|
body = [f'<line x1="{L}" y1="{B}" x2="{R}" y2="{B}" stroke="{GRAY}" stroke-width="1"/>']
|
|
xs = []
|
|
for i, bb in enumerate(bars):
|
|
x = L + slot * i + (slot - bw) / 2
|
|
xs.append((x, x + bw))
|
|
y_top = yv(bb["top"])
|
|
y_bot = yv(bb["bottom"])
|
|
body.append(
|
|
f'<rect x="{x:.1f}" y="{y_top:.1f}" width="{bw:.1f}" height="{max(1,y_bot-y_top):.1f}" '
|
|
f'fill="{bb["color"]}" rx="1"/>'
|
|
)
|
|
vlabel = _fmt(bb["val"], unit)
|
|
if bb.get("delta") and bb["val"] > 0:
|
|
vlabel = "+" + vlabel
|
|
body.append(_text(x + bw / 2, y_top - 7, vlabel, size=13, color=INK, anchor="middle", weight="bold"))
|
|
for j, ln in enumerate(_wrap(bb["label"], 14)):
|
|
body.append(_text(x + bw / 2, B + 20 + j * 14, ln, size=12, color=MUTE, anchor="middle"))
|
|
# connectors (dashed) at cumulative levels
|
|
for i in range(n - 1):
|
|
lv = levels[i]
|
|
yl = yv(lv)
|
|
body.append(
|
|
f'<line x1="{xs[i][1]:.1f}" y1="{yl:.1f}" x2="{xs[i+1][0]:.1f}" y2="{yl:.1f}" '
|
|
f'stroke="{MUTE}" stroke-width="1" stroke-dasharray="3,3"/>'
|
|
)
|
|
if caption:
|
|
body.append(_text(L, T - 30, caption, size=13, color=MUTE))
|
|
return _svg(W, H, "".join(body))
|
|
|
|
|
|
# ---------------------------------------------------------------- 2x2 matrix
|
|
def matrix2x2(x_label, y_label, items, x_lo="낮음", x_hi="높음", y_lo="낮음", y_hi="높음",
|
|
quadrants=None, caption=""):
|
|
"""items=[{name,x(0..1),y(0..1),size(0..1 opt),accent(bool opt)}]."""
|
|
W, H = 780, 600
|
|
L, R, T, B = 150, W - 40, 60, H - 90
|
|
plotW, plotH = R - L, B - T
|
|
midx, midy = L + plotW / 2, T + plotH / 2
|
|
body = []
|
|
# quadrant background labels
|
|
if quadrants:
|
|
qpos = [(L + plotW * 0.25, T + plotH * 0.12), (L + plotW * 0.75, T + plotH * 0.12),
|
|
(L + plotW * 0.25, B - plotH * 0.06), (L + plotW * 0.75, B - plotH * 0.06)]
|
|
for (qx, qy), lab in zip(qpos, quadrants):
|
|
body.append(_text(qx, qy, lab, size=13, color="#93a0ad", anchor="middle", weight="bold"))
|
|
# frame + mid axes
|
|
body.append(f'<rect x="{L}" y="{T}" width="{plotW}" height="{plotH}" fill="none" stroke="{GRIDL}" stroke-width="1.5"/>')
|
|
body.append(f'<line x1="{midx}" y1="{T}" x2="{midx}" y2="{B}" stroke="{GRAY}" stroke-width="1"/>')
|
|
body.append(f'<line x1="{L}" y1="{midy}" x2="{R}" y2="{midy}" stroke="{GRAY}" stroke-width="1"/>')
|
|
# axis labels
|
|
body.append(_text(midx, B + 52, x_label, size=15, color=INK, anchor="middle", weight="bold"))
|
|
body.append(_text(L - 6, B + 22, x_lo, size=12, color=MUTE, anchor="start"))
|
|
body.append(_text(R, B + 22, x_hi, size=12, color=MUTE, anchor="end"))
|
|
body.append(f'<text x="26" y="{midy:.1f}" font-size="15" fill="{INK}" text-anchor="middle" font-weight="bold" transform="rotate(-90 26 {midy:.1f})">{_esc(y_label)}</text>')
|
|
body.append(_text(30, B - 4, y_lo, size=12, color=MUTE, anchor="start"))
|
|
body.append(_text(30, T + 12, y_hi, size=12, color=MUTE, anchor="start"))
|
|
# bubbles
|
|
for it in items:
|
|
cx = L + it["x"] * plotW
|
|
cy = B - it["y"] * plotH
|
|
r = 10 + it.get("size", 0.4) * 34
|
|
col = ACCENT if it.get("accent") else NAVY
|
|
body.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" fill="{col}" fill-opacity="0.82"/>')
|
|
for j, ln in enumerate(_wrap(it["name"], 16)):
|
|
body.append(_text(cx, cy + r + 14 + j * 13, ln, size=12, color=INK, anchor="middle", weight="bold" if it.get("accent") else "normal"))
|
|
if caption:
|
|
body.append(_text(L, T - 26, caption, size=13, color=MUTE))
|
|
return _svg(W, H, "".join(body))
|
|
|
|
|
|
# ---------------------------------------------------------------- harvey balls
|
|
def _harvey(cx, cy, r, fill4):
|
|
"""fill4 in 0..4 → 0/25/50/75/100% pie. outline + navy filled wedge."""
|
|
out = [f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" fill="#fff" stroke="{NAVY}" stroke-width="1.4"/>']
|
|
frac = max(0, min(4, fill4)) / 4.0
|
|
if frac <= 0:
|
|
return "".join(out)
|
|
if frac >= 1:
|
|
out.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" fill="{NAVY}"/>')
|
|
return "".join(out)
|
|
ang = frac * 2 * math.pi
|
|
ex = cx + r * math.sin(ang)
|
|
ey = cy - r * math.cos(ang)
|
|
large = 1 if frac > 0.5 else 0
|
|
out.append(f'<path d="M {cx:.1f} {cy:.1f} L {cx:.1f} {cy-r:.1f} A {r:.1f} {r:.1f} 0 {large} 1 {ex:.1f} {ey:.1f} Z" fill="{NAVY}"/>')
|
|
return "".join(out)
|
|
|
|
|
|
def harvey(cols, rows, caption="", legend="● 충족 ◑ 부분 ○ 미흡"):
|
|
"""cols=[str], rows=[{name, fills:[0..4 per col], accent(opt)}]."""
|
|
nameW = 250
|
|
cellW = max(90, (760 - nameW) // max(1, len(cols)))
|
|
W = nameW + cellW * len(cols) + 20
|
|
rowH = 46
|
|
headH = 64
|
|
H = headH + rowH * len(rows) + 44
|
|
L, T = 20, 20
|
|
body = []
|
|
# header
|
|
for j, c in enumerate(cols):
|
|
cx = L + nameW + cellW * j + cellW / 2
|
|
for k, ln in enumerate(_wrap(c, 12)):
|
|
body.append(_text(cx, T + 18 + k * 14, ln, size=12, color=INK, anchor="middle", weight="bold"))
|
|
body.append(f'<line x1="{L}" y1="{T+headH-8}" x2="{W-10}" y2="{T+headH-8}" stroke="{NAVY}" stroke-width="1.4"/>')
|
|
for i, row in enumerate(rows):
|
|
ry = T + headH + rowH * i
|
|
cyc = ry + rowH / 2 - 2
|
|
accent = row.get("accent")
|
|
if accent:
|
|
body.append(f'<rect x="{L}" y="{ry-4}" width="{W-L-10}" height="{rowH}" fill="{ACCENT}" fill-opacity="0.08"/>')
|
|
for k, ln in enumerate(_wrap(row["name"], 30)):
|
|
body.append(_text(L + 4, cyc - 4 + k * 14, ln, size=13, color=INK, anchor="start",
|
|
weight="bold" if accent else "normal"))
|
|
for j, f in enumerate(row["fills"]):
|
|
cx = L + nameW + cellW * j + cellW / 2
|
|
body.append(_harvey(cx, cyc, 13, f))
|
|
body.append(f'<line x1="{L}" y1="{ry+rowH-4}" x2="{W-10}" y2="{ry+rowH-4}" stroke="{GRIDL}" stroke-width="1"/>')
|
|
if legend:
|
|
body.append(_text(L + 4, H - 16, legend, size=12, color=MUTE))
|
|
if caption:
|
|
body.append(_text(W - 10, H - 16, caption, size=12, color=MUTE, anchor="end"))
|
|
return _svg(W, H, "".join(body))
|
|
|
|
|
|
# ---------------------------------------------------------------- value chain
|
|
def valuechain(primary, support, margin_label="마진", caption=""):
|
|
"""Porter value chain. support=[str] (상단 가로 바), primary=[str] (하단 chevron)."""
|
|
W, H = 900, 420
|
|
L, R, T = 40, W - 40, 40
|
|
supH = 40
|
|
n_sup = len(support)
|
|
body = []
|
|
body.append(_text(L, T - 12, caption or "Value Chain", size=13, color=MUTE))
|
|
# support activities (stacked full-width bars)
|
|
for i, s in enumerate(support):
|
|
y = T + i * (supH + 6)
|
|
body.append(f'<rect x="{L}" y="{y}" width="{R-L-70}" height="{supH}" fill="{GRIDL}" stroke="{GRAY}" stroke-width="1" rx="3"/>')
|
|
body.append(_text(L + 12, y + supH / 2 + 5, s, size=13, color=INK, anchor="start"))
|
|
# primary activities (chevrons)
|
|
py = T + n_sup * (supH + 6) + 30
|
|
ph = 92
|
|
n = len(primary)
|
|
avail = (R - L - 70)
|
|
cw = avail / n
|
|
notch = 20
|
|
for i, p in enumerate(primary):
|
|
x = L + cw * i
|
|
x2 = x + cw
|
|
if i == 0:
|
|
d = f'M {x} {py} L {x2-notch} {py} L {x2} {py+ph/2} L {x2-notch} {py+ph} L {x} {py+ph} Z'
|
|
else:
|
|
d = f'M {x} {py} L {x2-notch} {py} L {x2} {py+ph/2} L {x2-notch} {py+ph} L {x} {py+ph} L {x+notch} {py+ph/2} Z'
|
|
body.append(f'<path d="{d}" fill="{NAVY}" fill-opacity="{0.72 + 0.04*i:.2f}" stroke="#fff" stroke-width="1.5"/>')
|
|
for k, ln in enumerate(_wrap(p, 12)):
|
|
body.append(_text(x + cw / 2 + notch / 2, py + ph / 2 - 4 + k * 14, ln, size=12, color="#fff", anchor="middle", weight="bold"))
|
|
# margin chevron on right
|
|
mx = L + avail
|
|
body.append(f'<path d="M {mx} {T} L {mx+70} {T} L {mx+70} {py+ph} L {mx} {py+ph} L {mx+34} {(T+py+ph)/2} Z" fill="{ACCENT}" fill-opacity="0.9"/>')
|
|
body.append(f'<text x="{mx+42:.1f}" y="{(T+py+ph)/2:.1f}" font-size="13" fill="#fff" text-anchor="middle" font-weight="bold" transform="rotate(-90 {mx+42:.1f} {(T+py+ph)/2:.1f})">{_esc(margin_label)}</text>')
|
|
return _svg(W, H, "".join(body))
|
|
|
|
|
|
# ---------------------------------------------------------------- benchmark bars
|
|
def benchmark_bars(series, highlight=None, unit="", caption="", title=""):
|
|
"""series=[{label,value}]. highlight=label(강조=accent). 내림차순 랭킹 가로 바."""
|
|
data = sorted(series, key=lambda d: d["value"], reverse=True)
|
|
W = 900
|
|
L, R, T = 230, W - 90, 50
|
|
barH, gap = 30, 14
|
|
H = T + len(data) * (barH + gap) + 30
|
|
vmax = max(d["value"] for d in data) or 1
|
|
body = []
|
|
if title:
|
|
body.append(_text(20, 28, title, size=15, color=INK, weight="bold"))
|
|
for i, d in enumerate(data):
|
|
y = T + i * (barH + gap)
|
|
w = (d["value"] / vmax) * (R - L)
|
|
acc = (highlight is not None and d["label"] == highlight)
|
|
col = ACCENT if acc else GRAY
|
|
body.append(_text(L - 12, y + barH / 2 + 5, d["label"], size=13, color=INK, anchor="end",
|
|
weight="bold" if acc else "normal"))
|
|
body.append(f'<rect x="{L}" y="{y}" width="{max(2,w):.1f}" height="{barH}" fill="{col}" rx="2"/>')
|
|
body.append(_text(L + w + 8, y + barH / 2 + 5, _fmt(d["value"], unit), size=13,
|
|
color=INK if acc else MUTE, anchor="start", weight="bold" if acc else "normal"))
|
|
body.append(f'<line x1="{L}" y1="{T-8}" x2="{L}" y2="{T + len(data)*(barH+gap)-gap+4}" stroke="{GRAY}" stroke-width="1"/>')
|
|
if caption:
|
|
body.append(_text(20, H - 12, caption, size=12, color=MUTE))
|
|
return _svg(W, H, "".join(body))
|
|
|
|
|
|
# ---------------------------------------------------------------- issue tree
|
|
def _tree_leaves(node):
|
|
kids = node.get("children") or []
|
|
if not kids:
|
|
return 1
|
|
return sum(_tree_leaves(k) for k in kids)
|
|
|
|
|
|
def issuetree(root, caption=""):
|
|
"""root={label, children:[{label, children:[...]}]}. 좌→우 MECE 분해(최대 3레벨)."""
|
|
leaves = _tree_leaves(root)
|
|
rowH = 54
|
|
H = max(200, leaves * rowH + 40)
|
|
W = 900
|
|
levelX = [30, 300, 560]
|
|
boxW = [230, 230, 300]
|
|
T = 20
|
|
body = []
|
|
|
|
def layout(node, depth, y0, y1):
|
|
cy = (y0 + y1) / 2
|
|
x = levelX[min(depth, 2)]
|
|
bw = boxW[min(depth, 2)]
|
|
color = NAVY if depth == 0 else (INK if depth == 1 else MUTE)
|
|
fill = "#eef2f7" if depth == 0 else "#ffffff"
|
|
stroke = NAVY if depth == 0 else GRAY
|
|
lines = _wrap(node["label"], 26 if depth == 0 else 30)
|
|
bh = max(34, len(lines) * 15 + 14)
|
|
body.append(f'<rect x="{x}" y="{cy-bh/2:.1f}" width="{bw}" height="{bh:.1f}" rx="4" fill="{fill}" stroke="{stroke}" stroke-width="1.4"/>')
|
|
for k, ln in enumerate(lines):
|
|
body.append(_text(x + 10, cy - bh / 2 + 18 + k * 15, ln, size=13, color=color,
|
|
anchor="start", weight="bold" if depth == 0 else "normal"))
|
|
kids = node.get("children") or []
|
|
if not kids:
|
|
return
|
|
total = _tree_leaves(node)
|
|
yy = y0
|
|
for kid in kids:
|
|
share = _tree_leaves(kid) / total
|
|
ky0, ky1 = yy, yy + share * (y1 - y0)
|
|
kcy = (ky0 + ky1) / 2
|
|
kx = levelX[min(depth + 1, 2)]
|
|
# elbow connector
|
|
midx = (x + bw + kx) / 2
|
|
body.append(f'<path d="M {x+bw} {cy:.1f} H {midx:.1f} V {kcy:.1f} H {kx:.1f}" fill="none" stroke="{GRAY}" stroke-width="1.3"/>')
|
|
layout(kid, depth + 1, ky0, ky1)
|
|
yy = ky1
|
|
|
|
layout(root, 0, T, T + leaves * rowH)
|
|
if caption:
|
|
body.append(_text(30, H - 10, caption, size=12, color=MUTE))
|
|
return _svg(W, H, "".join(body))
|
|
|
|
|
|
# ---------------------------------------------------------------- process flow
|
|
def process(steps, caption=""):
|
|
"""steps=[str] 또는 [{label, sub(opt)}]. 좌→우 번호형 chevron 흐름(4~6 권장)."""
|
|
norm = [s if isinstance(s, dict) else {"label": s} for s in steps]
|
|
W, H = 900, 220
|
|
L, R = 30, W - 30
|
|
n = len(norm)
|
|
cw = (R - L) / n
|
|
py, ph = 70, 96
|
|
notch = 22
|
|
body = []
|
|
if caption:
|
|
body.append(_text(L, 34, caption, size=14, color=INK, weight="bold"))
|
|
for i, s in enumerate(norm):
|
|
x = L + cw * i
|
|
x2 = x + cw - 8
|
|
if i == 0:
|
|
d = f'M {x} {py} L {x2-notch} {py} L {x2} {py+ph/2} L {x2-notch} {py+ph} L {x} {py+ph} Z'
|
|
else:
|
|
d = f'M {x} {py} L {x2-notch} {py} L {x2} {py+ph/2} L {x2-notch} {py+ph} L {x} {py+ph} L {x+notch} {py+ph/2} Z'
|
|
col = NAVY if i % 2 == 0 else "#2c517d"
|
|
body.append(f'<path d="{d}" fill="{col}" stroke="#fff" stroke-width="1.5"/>')
|
|
cx = x + (cw) / 2 + notch / 2
|
|
body.append(_text(cx, py + 26, f"{i+1}", size=17, color=ACCENT, anchor="middle", weight="bold"))
|
|
for k, ln in enumerate(_wrap(s["label"], 13)):
|
|
body.append(_text(cx, py + 48 + k * 15, ln, size=12, color="#fff", anchor="middle", weight="bold"))
|
|
if s.get("sub"):
|
|
for k, ln in enumerate(_wrap(s["sub"], 16)):
|
|
body.append(_text(cx, py + ph + 18 + k * 13, ln, size=11, color=MUTE, anchor="middle"))
|
|
return _svg(W, H, "".join(body))
|
|
|
|
|
|
# ---------------------------------------------------------------- dispatcher
|
|
def render_exhibit(ex):
|
|
"""ex = {type, ...data}. 알 수 없는 type이면 None."""
|
|
if not isinstance(ex, dict):
|
|
return None
|
|
t = ex.get("type")
|
|
try:
|
|
if t == "waterfall":
|
|
return waterfall(tuple(ex["start"]), [tuple(d) for d in ex["deltas"]], tuple(ex["end"]),
|
|
unit=ex.get("unit", ""), caption=ex.get("caption", ""))
|
|
if t == "matrix2x2":
|
|
return matrix2x2(ex["x-label"], ex["y-label"], ex["items"],
|
|
x_lo=ex.get("x-lo", "낮음"), x_hi=ex.get("x-hi", "높음"),
|
|
y_lo=ex.get("y-lo", "낮음"), y_hi=ex.get("y-hi", "높음"),
|
|
quadrants=ex.get("quadrants"), caption=ex.get("caption", ""))
|
|
if t == "harvey":
|
|
return harvey(ex["cols"], ex["rows"], caption=ex.get("caption", ""),
|
|
legend=ex.get("legend", "● 충족 ◑ 부분 ○ 미흡"))
|
|
if t == "valuechain":
|
|
return valuechain(ex["primary"], ex.get("support", []),
|
|
margin_label=ex.get("margin", "마진"), caption=ex.get("caption", ""))
|
|
if t == "benchmark":
|
|
return benchmark_bars(ex["series"], highlight=ex.get("highlight"),
|
|
unit=ex.get("unit", ""), caption=ex.get("caption", ""),
|
|
title=ex.get("title", ""))
|
|
if t == "issuetree":
|
|
return issuetree(ex["root"], caption=ex.get("caption", ""))
|
|
if t == "process":
|
|
return process(ex["steps"], caption=ex.get("caption", ""))
|
|
except (KeyError, TypeError, ValueError) as e:
|
|
# 아키타입 렌더 실패도 열화(degraded) — 조용히 "성공"시키지 않고 마커를 심는다.
|
|
return degraded_svg(f"exhibit:{t}", code=f"{t}: {e}", title=f"exhibit {t} 데이터 오류")
|
|
return None
|
|
|
|
|
|
TYPES = ["waterfall", "matrix2x2", "harvey", "valuechain", "benchmark", "issuetree", "process"]
|
|
|
|
|
|
def _demo():
|
|
exs = {
|
|
"waterfall": {"type": "waterfall", "unit": "%", "start": ["현재 준수도", 35],
|
|
"deltas": [["의존성 역전", 18], ["경계 계층화", 15], ["테스트 격리", 12], ["암묵 결합", -8]],
|
|
"end": ["목표", 72], "caption": "클린아키텍처 준수도 브리지"},
|
|
"matrix2x2": {"type": "matrix2x2", "x-label": "실행 난이도", "y-label": "아키텍처 임팩트",
|
|
"x-lo": "쉬움", "x-hi": "어려움", "y-lo": "낮음", "y-hi": "높음",
|
|
"quadrants": ["Quick Win", "Big Bet", "Fill-in", "Thankless"],
|
|
"items": [{"name": "의존성 역전", "x": 0.35, "y": 0.85, "size": 0.7, "accent": True},
|
|
{"name": "포트 정의", "x": 0.3, "y": 0.6, "size": 0.5},
|
|
{"name": "이벤트 도입", "x": 0.8, "y": 0.7, "size": 0.6}]},
|
|
"harvey": {"type": "harvey", "cols": ["의존성 규칙", "경계 명확", "테스트성", "변경 국소성"],
|
|
"rows": [{"name": "현재 시스템", "fills": [2, 1, 2, 1], "accent": True},
|
|
{"name": "목표 상태", "fills": [4, 4, 4, 3]}]},
|
|
"valuechain": {"type": "valuechain", "support": ["빌드·CI", "관측성", "보안"],
|
|
"primary": ["도메인", "유스케이스", "인터페이스 어댑터", "인프라"], "margin": "가치"},
|
|
"benchmark": {"type": "benchmark", "unit": "%", "highlight": "우리 시스템",
|
|
"title": "레이어 격리도 벤치마크",
|
|
"series": [{"label": "업계 상위", "value": 88}, {"label": "우리 시스템", "value": 54},
|
|
{"label": "평균", "value": 61}]},
|
|
"issuetree": {"type": "issuetree",
|
|
"root": {"label": "왜 변경이 어려운가?", "children": [
|
|
{"label": "결합", "children": [{"label": "도메인→프레임워크 의존"}, {"label": "순환 참조"}]},
|
|
{"label": "테스트", "children": [{"label": "DB 없이 테스트 불가"}]}]}},
|
|
"process": {"type": "process", "caption": "적용 로드맵",
|
|
"steps": [{"label": "경계 식별", "sub": "1주"}, {"label": "포트 정의", "sub": "2주"},
|
|
{"label": "의존성 역전", "sub": "3주"}, {"label": "검증", "sub": "1주"}]},
|
|
}
|
|
import os
|
|
d = os.path.join(os.path.dirname(__file__), "..", "..", "scratch-exhibits")
|
|
return exs
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
exs = _demo()
|
|
which = sys.argv[1] if len(sys.argv) > 1 else "all"
|
|
for name, ex in exs.items():
|
|
if which not in ("all", name):
|
|
continue
|
|
svg = render_exhibit(ex)
|
|
print(f"<!-- {name} -->")
|
|
print(svg[:120] + " ... " + svg[-40:] if svg else "None")
|
|
print(f"OK consult_exhibits: {len(TYPES)} types")
|