Diagram generation: ref-arch-driven procedure + spec validator + KB enrichment

The diagram path now follows a documented standard procedure (lookup
the closest Oracle Architecture Center reference → confirm components
→ author absolute_layout → spec validator → render → visually verify)
and ships persistent guardrails so layout regressions can't recur.

Persistent procedure changes (apply to all users, all sessions):
- tools/diagram_spec_validator.py — geometry checks (CONTAINER_TOO_THIN,
  CONTAINER_PADDING_VIOLATION, LABEL_OVERFLOW_PARENT) run BEFORE either
  renderer (drawio + PPTX). Catches the subnet-collapse / label-overflow
  bugs that the post-render drawio validator missed.
- tools/oci_diagram_gen.py + tools/oci_pptx_diagram_gen.py — call the
  spec validator before emitting any output. Adds mysql / mysql_heatwave
  type aliases.
- tools/archcenter_pattern_lookup.py — scores against cached page
  descriptions (not just the 1-line summary), supports --queries for
  multi-fragment composition, and applies synonym expansion via
  kb/architecture-center/synonyms.yaml so "LB HA cross AD" matches
  "load balancer high availability availability domain".
- kb/architecture-center/synonyms.yaml — canonical synonym table
  (load balancer, autonomous database, data guard, …) used by the
  lookup scorer.

KB enrichment:
- tools/archcenter_description_fetcher.py + 121 cached _description.md
  under kb/diagram/assets/archcenter-refs/<slug>/. Removes the runtime
  dependency on docs.oracle.com when authoring specs and feeds the
  pattern-lookup scorer.
- 110+ cached .drawio / .svg / .png references for offline reuse,
  plus the OCI Toolkit v24.2 import (kb/diagram/assets/oci-toolkit-drawio).

Documentation:
- docs/skill/output-formats.md — new "Standard diagram-generation
  procedure (MANDATORY)" + geometry rules + the new validator entry.
- SKILL.md option 2 — references the mandatory procedure.
- README.md — describes the spec validator, archcenter_pattern_lookup
  and description fetcher, and updates the KB-health table.

Tooling that backs the procedure (cumulative across recent sessions):
tools/archcenter_case_runner.py, archcenter_batch_driver.py,
archcenter_zip_downloader.py, drawio_visual_validator.py,
drawio_fidelity_eval.py, harvest_drawio_icon.py, import_oci_library.py,
oci_pptx_diagram_gen.py, oci_pptx_render.py, refresh_pptx_icon_index.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-25 21:15:21 -03:00
parent 2491c38d4b
commit b30a4f0d32
635 changed files with 365317 additions and 1014 deletions

View File

@@ -0,0 +1,739 @@
#!/usr/bin/env python3
"""
OCI Side-by-Side Comparison Diagram Generator.
Produces .drawio files with the AS IS / TO BE panel layout used for
ADB-S vs ADB-D, Active Data Guard mode, refreshable-clone variants, etc.
Each panel contains:
- A gray rounded container
- A title banner at top (e.g., "AS IS | ADB-S for low-criticality production")
- An optional Notes box (cream/yellow background) at top-right
- An OCI region with side-by-side Availability Domains,
each AD containing one or more "pools" (R/W Pool, R/O Pool, ...),
each pool containing ADB/ATP icons
- An AWS cloud container with App Layer + named services
- An optional Trade-offs box at bottom
Connections are routed between services using stable user-provided ids.
CLI:
python3 tools/oci_compare_diagram_gen.py \
--spec examples/.../adbs-vs-adbd-compare-spec.yaml \
--output out.drawio
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import Optional
import yaml
# Reuse icon cache + per-service category styles from the main generator.
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from oci_diagram_gen import ( # noqa: E402
OCIDiagramGenerator,
STYLES as BASE_STYLES,
SVC_CATEGORY,
_aws_icon_style,
)
# ---------------------------------------------------------------------------
# Panel-specific styles
# ---------------------------------------------------------------------------
# Oracle palette (see oci_diagram_gen.STYLES header for the full palette).
# Panels are rendered with the same warm grays as region containers so that
# the AS IS / TO BE scenarios read as peer artefacts on the canvas.
PANEL_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#F5F4F2;strokeColor=#9E9892;"
"fontColor=#312D2A;strokeWidth=1;arcSize=2;align=left;verticalAlign=top;"
"fontFamily=Oracle Sans;fontSize=13;fontStyle=1;spacingLeft=12;spacingTop=8;"
)
# Title banner variants — tone selects the color used for the banner fill.
# 'as_is' uses muted slate (current state), 'to_be' uses teal (target state).
TITLE_BANNER_STYLES = {
"as_is": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#70665E;strokeColor=none;"
"fontColor=#FCFBFA;fontSize=13;fontStyle=1;fontFamily=Oracle Sans;"
"align=left;verticalAlign=middle;spacingLeft=14;arcSize=8;"
),
"to_be": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#2D5967;strokeColor=none;"
"fontColor=#FCFBFA;fontSize=13;fontStyle=1;fontFamily=Oracle Sans;"
"align=left;verticalAlign=middle;spacingLeft=14;arcSize=8;"
),
"neutral": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#312D2A;strokeColor=none;"
"fontColor=#FCFBFA;fontSize=13;fontStyle=1;fontFamily=Oracle Sans;"
"align=left;verticalAlign=middle;spacingLeft=14;arcSize=8;"
),
}
NOTES_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9E6;strokeColor=#D4B84A;"
"strokeWidth=1;fontColor=#312D2A;fontSize=10;fontFamily=Oracle Sans;"
"align=left;verticalAlign=top;spacingLeft=10;spacingTop=8;spacingRight=8;"
"spacingBottom=8;arcSize=6;"
)
TRADEOFFS_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FDF4EA;strokeColor=#AA643B;"
"strokeWidth=1;fontColor=#AA643B;fontSize=10;fontFamily=Oracle Sans;"
"align=left;verticalAlign=top;spacingLeft=10;spacingTop=8;spacingRight=8;"
"spacingBottom=8;arcSize=6;fontStyle=0;"
)
OCI_REGION_STYLE = BASE_STYLES["region"]
AD_STYLE = BASE_STYLES["ad"]
POOL_STYLE = BASE_STYLES["compartment"]
# Pool tones mirror the reference adbsplusadbd.drawio: green for R/W,
# blue for R/O, neutral warm gray for anything else.
POOL_TONE_STYLES = {
"rw": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#E6F0E0;strokeColor=#4E7A3F;"
"strokeWidth=1;fontColor=#2F5A28;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
"spacingLeft=10;spacingTop=6;arcSize=4;"
),
"ro": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#E6EEF6;strokeColor=#3B5B82;"
"strokeWidth=1;fontColor=#24405F;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
"spacingLeft=10;spacingTop=6;arcSize=4;"
),
"neutral": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#F2EFEA;strokeColor=#9E9892;"
"strokeWidth=1;fontColor=#312D2A;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
"spacingLeft=10;spacingTop=6;arcSize=4;"
),
}
# A data-guard annotation bubble: white pill with a subtle drop shadow-ish
# border that sits on top of the replication arrow.
DG_LABEL_STYLES = {
"rw": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#4E7A3F;strokeColor=#2F5A28;"
"strokeWidth=1;fontColor=#FCFBFA;fontSize=10;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=20;"
),
"ro": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#3B5B82;strokeColor=#24405F;"
"strokeWidth=1;fontColor=#FCFBFA;fontSize=10;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=20;"
),
"neutral": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#2D5967;strokeColor=#1F3F48;"
"strokeWidth=1;fontColor=#FCFBFA;fontSize=10;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=20;"
),
}
SIDE_NOTE_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF4D6;strokeColor=#D4B84A;"
"strokeWidth=1;fontColor=#5A4A10;fontSize=9;fontStyle=0;"
"fontFamily=Oracle Sans;align=left;verticalAlign=top;spacingLeft=6;"
"spacingTop=4;arcSize=4;"
)
AWS_CLOUD_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=6 4;"
"fillColor=none;fontColor=#232F3E;strokeColor=#232F3E;"
"fontFamily=Oracle Sans;fontSize=12;fontStyle=1;align=left;"
"verticalAlign=top;spacingLeft=8;spacingTop=6;arcSize=6;"
)
APP_LAYER_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;strokeColor=#70665E;"
"strokeWidth=1;fontColor=#312D2A;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=top;spacingTop=6;"
"arcSize=6;"
)
SERVICE_PILL_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;strokeColor=#2D5967;"
"strokeWidth=1;fontColor=#2D5967;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=10;"
)
STANDBY_PILL_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;strokeColor=#9E9892;"
"strokeWidth=1;fontColor=#70665E;fontSize=11;fontStyle=2;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=10;"
)
CLONE_PILL_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;strokeColor=#804998;"
"strokeWidth=1;fontColor=#804998;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=10;"
)
TITLE_STYLE = (
"text;html=1;fontSize=18;fontColor=#312D2A;fontFamily=Oracle Sans;"
"align=center;verticalAlign=middle;fontStyle=1;"
)
def _pill_style(kind: str) -> str:
kind = (kind or "").lower()
if kind in ("standby", "passive", "dormant"):
return STANDBY_PILL_STYLE
if kind in ("clone", "refreshable_clone", "refreshable-clone", "refreshable"):
return CLONE_PILL_STYLE
return SERVICE_PILL_STYLE
# ---------------------------------------------------------------------------
# Layout constants (pixels)
# ---------------------------------------------------------------------------
PANEL_W = 1080
PANEL_H = 620
PANEL_GAP = 40
TITLE_BANNER_H = 30
NOTES_BOX_H = 70
TRADEOFFS_BOX_H = 56
TITLE_BANNER_Y = 10 # title banner sits 10px from panel top
CONTENT_TOP_Y = 130 # y where OCI+AWS content starts (below title+notes)
CONTENT_BOTTOM_PAD = 80 # room reserved at bottom for trade-offs box
AWS_CLOUD_W = 240
AD_GAP = 14
POOL_GAP = 10
SERVICE_PILL_W = 150
SERVICE_PILL_H = 50
class CompareDiagram:
"""Build and serialize a side-by-side comparison diagram."""
def __init__(self):
self.gen = OCIDiagramGenerator()
# OCIDiagramGenerator creates a drawpyo File + Page in __init__.
# We'll use its helpers (add_service, add_connection, icon cache) and
# add raw panel / AD / pool containers via internal _create_object.
# -------------------- primitive emitters --------------------
def _text_block(
self,
cell_id: str,
label: str,
style: str,
parent: str,
x: int,
y: int,
w: int,
h: int,
):
self.gen._create_object(cell_id, label, style, parent, x, y, w, h)
# -------------------- high-level panel build --------------------
def build_panel(self, panel: dict, panel_x: int):
panel_id = panel["id"]
tone = panel.get("tone", "as_is")
banner_style = TITLE_BANNER_STYLES.get(tone, TITLE_BANNER_STYLES["neutral"])
panel_w = panel.get("w", PANEL_W)
# Pre-compute panel height from the OCI region's matrix content so
# the trade-offs box lands just below the region instead of floating
# in dead space.
oci_spec_pre = panel.get("oci") or {}
pools_pre = oci_spec_pre.get("pools", []) or []
pool_total = sum(
p.get("h", 22 + 150 + 16) for p in pools_pre
) + POOL_GAP * max(0, len(pools_pre) - 1)
content_needed = 44 + 30 + pool_total + 30 # region top + AD header + pool rows + bottom
auto_panel_h = CONTENT_TOP_Y + content_needed + 18 + TRADEOFFS_BOX_H + 18
panel_h = panel.get("h", auto_panel_h)
# --- Panel container ---
self._text_block(
panel_id, "", PANEL_STYLE, None,
panel_x, panel.get("y", 70), panel_w, panel_h,
)
# --- Title banner at top, sized to leave room for notes on the right ---
notes_text = panel.get("notes")
notes_w = panel.get("notes_w", 460) if notes_text else 0
title_right_pad = notes_w + 28 if notes_w else 28
self._text_block(
f"{panel_id}__title",
panel.get("title", ""),
banner_style,
panel_id,
14,
TITLE_BANNER_Y,
panel_w - title_right_pad - 8,
TITLE_BANNER_H,
)
# --- Notes box on the right, aligned with the title row ---
if notes_text:
self._text_block(
f"{panel_id}__notes",
notes_text,
NOTES_STYLE,
panel_id,
panel_w - notes_w - 14,
TITLE_BANNER_Y,
notes_w,
NOTES_BOX_H,
)
# --- Content zone positions ---
content_top = CONTENT_TOP_Y
content_bottom = panel_h - CONTENT_BOTTOM_PAD
content_h = content_bottom - content_top
aws_spec = panel.get("aws") or {}
oci_spec = panel.get("oci") or {}
dr_spec = panel.get("dr") or {}
# Left margin starts at 16; AWS (if any) goes first, then OCI primary,
# then optional DR region.
cursor_x = 16
if aws_spec:
aws_w = aws_spec.get("w", AWS_CLOUD_W)
aws_x = aws_spec.get("x", cursor_x)
aws_h = aws_spec.get("h", content_h)
self._build_aws_cloud(panel_id, aws_spec, aws_x, content_top,
aws_w, aws_h)
cursor_x = aws_x + aws_w + 20
# OCI primary region.
if dr_spec:
# Reserve space for the DR region on the right.
dr_w = dr_spec.get("w", max(260, int(panel_w * 0.22)))
else:
dr_w = 0
oci_x = oci_spec.get("x", cursor_x)
oci_w = oci_spec.get("w",
panel_w - oci_x - 16 - (dr_w + 20 if dr_spec else 0))
oci_h = oci_spec.get("h", content_h)
self._build_oci_region(panel_id, oci_spec, oci_x, content_top,
oci_w, oci_h)
# DR region on the right (cross-region standbys).
if dr_spec:
dr_x = dr_spec.get("x", oci_x + oci_w + 20)
dr_h = dr_spec.get("h", oci_h)
self._build_oci_region(panel_id, dr_spec, dr_x, content_top,
dr_w, dr_h)
# --- Trade-offs box at bottom ---
tradeoffs = panel.get("trade_offs") or panel.get("tradeoffs")
if tradeoffs:
self._text_block(
f"{panel_id}__tradeoffs",
tradeoffs,
TRADEOFFS_STYLE,
panel_id,
14,
panel_h - TRADEOFFS_BOX_H - 10,
panel_w - 28,
TRADEOFFS_BOX_H,
)
# --- Inter-panel connections (contained within this panel) ---
for conn in panel.get("connections", []) or []:
self._emit_connection(conn)
# -------------------- AWS cloud side --------------------
def _build_aws_cloud(
self,
panel_id: str,
aws_spec: dict,
x: int,
y: int,
w: int,
h: int,
):
if not aws_spec:
return
svcs = aws_spec.get("services", []) or []
pill_gap = 30
app_h = 50
# Auto-shrink the cloud to match the content stack height, so the
# container isn't a tall empty column when there are only a couple
# of services.
content_h = 38 + app_h + 20 + len(svcs) * (SERVICE_PILL_H + pill_gap) + 24
h = min(h, max(200, content_h))
cloud_id = aws_spec.get("id", f"{panel_id}__aws")
self._text_block(
cloud_id,
aws_spec.get("label", "AWS Cloud"),
AWS_CLOUD_STYLE,
panel_id,
x, y, w, h,
)
# App Layer header box at top
app_label = aws_spec.get("app_label", "App Layer")
app_w = max(80, w - 40)
app_x = (w - app_w) // 2
app_y = 38
self._text_block(
f"{cloud_id}__app",
app_label,
APP_LAYER_STYLE,
cloud_id,
app_x, app_y, app_w, app_h,
)
# Named service pills stacked below the App Layer header.
sy = app_y + app_h + 20
for svc in svcs:
svc_id = svc["id"]
label = svc.get("label", svc_id).replace("\\n", "\n")
pill_w = svc.get("w", min(w - 40, 180))
pill_h = svc.get("h", SERVICE_PILL_H)
pill_x = (w - pill_w) // 2
style = _pill_style(svc.get("kind", "service"))
self._text_block(svc_id, label, style, cloud_id,
pill_x, sy, pill_w, pill_h)
sy += pill_h + pill_gap
# -------------------- OCI region + ADs + pools --------------------
def _build_oci_region(
self,
panel_id: str,
oci_spec: dict,
x: int,
y: int,
w: int,
h: int,
):
"""Matrix layout: AD columns + pool rows that span them.
Layout matches the adbsplusadbd.drawio reference:
- ADs are narrow side-by-side columns that span the full region
content height (acting as swim lanes / column headers).
- Pools (R/W, R/O, ...) are horizontal rows stacked vertically
inside the region, each spanning both AD columns so the pool is
visually cross-AD.
- Databases (services) are placed inside a pool at an x-offset that
aligns with the AD column they belong to, based on ``svc.ad``.
"""
if not oci_spec:
return
ads = oci_spec.get("ads", []) or []
pools = oci_spec.get("pools", []) or []
# Databases directly inside the region (no pool/AD) — used for the
# Montreal DR column in the simple diagrams where we just want a
# stacked list of standbys under the region label.
direct_dbs = oci_spec.get("databases", []) or []
AD_HEADER_H = 30 if ads else 0
pool_heights = []
for pool in pools:
n_svcs = len(pool.get("services", []) or [])
pool_h = pool.get("h", 22 + 150 + 16)
pool_heights.append(pool_h)
direct_dbs_h = (150 * len(direct_dbs) + 20) if direct_dbs else 0
region_content_h = (
AD_HEADER_H + sum(pool_heights)
+ POOL_GAP * max(0, len(pool_heights) - 1)
+ direct_dbs_h + 20
)
auto_region_h = 44 + region_content_h
h = min(h, max(240, auto_region_h))
region_id = oci_spec.get("id", f"{panel_id}__oci")
self._text_block(
region_id,
oci_spec.get("label", "OCI Region"),
OCI_REGION_STYLE,
panel_id,
x, y, w, h,
)
# ── Direct databases (no AD/pool) ──
# Stack vertically inside the region. Used for Montreal DR.
if direct_dbs:
dy = 52
for db in direct_dbs:
db_id = db["id"]
label = db.get("label", db_id).replace("\\n", "\n")
db_type = db.get("type", "adb_s")
tone = db.get("tone")
db_w = db.get("w", min(w - 40, 180))
db_h = db.get("h", 140)
db_x = (w - db_w) // 2
self.gen.add_service(
db_id, label, db_type, region_id,
db_x, dy, db_w, db_h,
font_size=db.get("fontSize", 10),
tone=tone,
)
dy += db_h + 20
if not pools:
return
# --- Compute AD column geometry (may be empty) ---
ad_content_left = 20
ad_content_right = w - 20
ad_content_top = 44
ad_content_bottom = h - 16
ad_area_w = ad_content_right - ad_content_left
ad_cols: dict[str, tuple[int, int]] = {}
if ads:
total_gap_w = AD_GAP * max(0, len(ads) - 1)
ad_col_w = max(150, (ad_area_w - total_gap_w) // len(ads))
ax = ad_content_left
for ad in ads:
ad_id = ad["id"]
self._text_block(
ad_id,
ad.get("label", ad_id),
AD_STYLE,
region_id,
ax, ad_content_top, ad_col_w, ad_content_bottom - ad_content_top,
)
ad_cols[ad_id] = (ax, ad_col_w)
ax += ad_col_w + AD_GAP
pool_right_edge = ax - AD_GAP
else:
pool_right_edge = ad_content_right
# --- Lay out pool rows ---
pool_x = ad_content_left + 4
pool_w = pool_right_edge - pool_x
py = ad_content_top + AD_HEADER_H + 4
for pool, pool_h in zip(pools, pool_heights):
pool_id = pool["id"]
tone = (pool.get("tone") or "neutral").lower()
style = POOL_TONE_STYLES.get(tone, POOL_TONE_STYLES["neutral"])
self._text_block(
pool_id,
pool.get("label", ""),
style,
region_id,
pool_x, py, pool_w, pool_h,
)
# Services inside the pool. If any service references an ad (and
# ads are defined), use the matrix layout; otherwise distribute
# services evenly in a horizontal row inside the pool.
svcs = pool.get("services", []) or []
any_ad_ref = any(svc.get("ad") for svc in svcs) and ad_cols
if not any_ad_ref and svcs:
n = len(svcs)
gap = 16
default_w = max(130, (pool_w - 30 - gap * (n - 1)) // n)
sx = 15
for svc in svcs:
svc_id = svc["id"]
label = svc.get("label", svc_id).replace("\\n", "\n")
svc_type = svc.get("type", "adb_s")
tone_s = svc.get("tone")
svc_w = svc.get("w", default_w)
svc_h = svc.get("h", pool_h - 36)
self.gen.add_service(
svc_id, label, svc_type, pool_id,
sx, 24, svc_w, svc_h,
font_size=svc.get("fontSize", 10),
tone=tone_s,
)
sx += svc_w + gap
else:
for svc in svcs:
svc_id = svc["id"]
label = svc.get("label", svc_id).replace("\\n", "\n")
svc_type = svc.get("type", "adb_s")
tone_s = svc.get("tone")
ad_ref = svc.get("ad")
if ad_ref and ad_ref in ad_cols:
col_x, col_w = ad_cols[ad_ref]
svc_w = svc.get("w", min(col_w - 20, 140))
svc_h = svc.get("h", pool_h - 36)
svc_cx = col_x + col_w // 2
svc_x_in_pool = svc_cx - svc_w // 2 - pool_x
else:
svc_w = svc.get("w", 140)
svc_h = svc.get("h", pool_h - 36)
svc_x_in_pool = 16
svc_y_in_pool = svc.get("y", 24)
self.gen.add_service(
svc_id, label, svc_type, pool_id,
svc_x_in_pool, svc_y_in_pool, svc_w, svc_h,
font_size=svc.get("fontSize", 10),
tone=tone_s,
)
# Optional side-note attached to a pool (e.g., "A) Allow L-SBY...")
side_note = pool.get("side_note")
if side_note:
note_w = pool.get("side_note_w", 160)
note_h = pool.get("side_note_h", 40)
# Anchor it to the top-right of the pool, above any DB icons.
self._text_block(
f"{pool_id}__note",
side_note,
SIDE_NOTE_STYLE,
pool_id,
pool_w - note_w - 8, 2, note_w, note_h,
)
py += pool_h + POOL_GAP
# The old per-AD pool helper is no longer used under the matrix layout.
# Kept for backward-compat with any old spec that nests pools under ADs.
def _build_pools(self, ad_id, ad_spec, ad_w, ad_h):
pass
def _build_pools(self, ad_id: str, ad_spec: dict, ad_w: int, ad_h: int):
pools = ad_spec.get("pools", []) or []
if not pools:
return
# Stack pools vertically inside the AD. Each pool auto-sizes to its
# service content (one DB slot ≈ 150px), with a small cushion for the
# pool's own label row. We avoid stretching pools to fill the AD so
# the icons remain visually prominent instead of floating in empty
# space.
pool_top = 30
py = pool_top
for pool in pools:
n_svcs = len(pool.get("services", []) or [])
auto_h = 26 + max(n_svcs, 1) * 150 + 10 # label row + slots
pool_h = pool.get("h", auto_h)
pool_id = pool["id"]
self._text_block(
pool_id,
pool.get("label", ""),
POOL_STYLE,
ad_id,
10, py, ad_w - 20, pool_h,
)
self._build_pool_services(pool_id, pool, ad_w - 20, pool_h)
py += pool_h + POOL_GAP
def _build_pool_services(
self,
pool_id: str,
pool_spec: dict,
pool_w: int,
pool_h: int,
):
svcs = pool_spec.get("services", []) or []
if not svcs:
return
# Stack DB icons vertically inside the pool. add_service() renders
# the icon group + a sibling label text block, and the Oracle toolkit
# targets a 63px icon height — we reserve ~140px per service so icon
# + 2-line label fit cleanly without overlap.
n = len(svcs)
slot_h = max(140, (pool_h - 40) // n) if n else 0
sy = 28
for svc in svcs:
svc_id = svc["id"]
label = svc.get("label", svc_id).replace("\\n", "\n")
svc_type = svc.get("type", "adb_s")
svc_w = svc.get("w", min(pool_w - 20, 170))
svc_h = svc.get("h", slot_h - 6)
svc_x = (pool_w - svc_w) // 2
font_size = svc.get("fontSize") or 10
self.gen.add_service(
svc_id, label, svc_type, pool_id,
svc_x, sy, svc_w, svc_h, font_size=font_size,
)
sy += slot_h
# -------------------- connections --------------------
def _emit_connection(self, conn: dict):
self.gen.add_connection(
conn.get("id") or f"{conn['from']}_{conn['to']}",
conn.get("label"),
conn.get("type", "data"),
conn["from"],
conn["to"],
waypoints=conn.get("waypoints"),
flow_order=conn.get("flow_order"),
)
# -------------------- top-level build --------------------
@classmethod
def from_spec(cls, spec: dict) -> "CompareDiagram":
self = cls()
panels = spec.get("panels", []) or []
# Pre-compute every panel's auto-height so we can level all panels
# to the tallest one — visually balanced side-by-side comparison.
def _panel_auto_h(panel: dict) -> int:
pools = (panel.get("oci") or {}).get("pools", []) or []
pool_total = sum(
p.get("h", 22 + 150 + 16) for p in pools
) + POOL_GAP * max(0, len(pools) - 1)
content_needed = 44 + 30 + pool_total + 30
return CONTENT_TOP_Y + content_needed + 18 + TRADEOFFS_BOX_H + 18
max_h = max((_panel_auto_h(p) for p in panels), default=PANEL_H)
# Auto x layout if panel.x not provided.
cursor_x = 30
for panel in panels:
panel = dict(panel)
if "x" not in panel:
panel["x"] = cursor_x
# Force uniform height across panels.
panel["h"] = panel.get("h", max_h)
self.build_panel(panel, panel["x"])
cursor_x = panel["x"] + panel.get("w", PANEL_W) + PANEL_GAP
# Title across the top.
if spec.get("title"):
total_w = max(cursor_x, PANEL_W * 2 + PANEL_GAP)
self.gen._create_object("title", spec["title"], TITLE_STYLE,
None, 0, 14, total_w, 32)
# Cross-panel connections (e.g., narrate deltas) go at spec level.
for conn in spec.get("connections", []) or []:
self._emit_connection(conn)
return self
def save(self, output_path: str):
self.gen.save(output_path)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--spec", required=True, help="YAML spec file")
p.add_argument("--output", default="comparison.drawio",
help="Output .drawio path")
args = p.parse_args()
with open(args.spec, "r", encoding="utf-8") as f:
spec = yaml.safe_load(f)
diagram = CompareDiagram.from_spec(spec)
diagram.save(args.output)
print(f"Generated: {args.output}")
print(f" Panels: {len(spec.get('panels', []))}")
print(f" Containers: {diagram.gen._container_count}")
print(f" Services: {diagram.gen._service_count}")
print(f" Connections: {diagram.gen._connection_count}")
if __name__ == "__main__":
main()