#!/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·하비볼·밸류체인·벤치마크)는 여기서.
각 함수는 완결된 문자열을 반환한다(파일/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''
)
def _text(x, y, s, size=15, color=INK, anchor="start", weight="normal"):
return (
f'{_esc(s)}'
)
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"",
f'',
_text(14, 26, banner, size=13, color=NEG, weight="bold"),
]
for i, ln in enumerate(lines):
body.append(
f'{_esc(ln)}'
)
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'']
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''
)
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''
)
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'')
body.append(f'')
body.append(f'')
# 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'{_esc(y_label)}')
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'')
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'']
frac = max(0, min(4, fill4)) / 4.0
if frac <= 0:
return "".join(out)
if frac >= 1:
out.append(f'')
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'')
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'')
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'')
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'')
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'')
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'')
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'')
body.append(f'{_esc(margin_label)}')
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'')
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'')
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'')
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'')
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'')
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"")
print(svg[:120] + " ... " + svg[-40:] if svg else "None")
print(f"OK consult_exhibits: {len(TYPES)} types")