#!/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'' f'{body}' ) 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")