← 返回
未分类

多实体关系ER图

生成无属性、仅实体+联系的黑白简约ER图。输入实体和关系列表,自动检测图结构选择最优布局,输出PNG。支持基数标注,内置交叉/重叠/间距三项自动验证。当用户给出系统名称(如"电影院票务管理系统")或实体关系列表时触发。不用本工具画单实体属性图。
生成无属性、仅实体+联系的黑白简约ER图。输入实体和关系列表,自动检测图结构选择最优布局,输出PNG。支持基数标注,内置交叉/重叠/间距三项自动验证。当用户给出系统名称(如"电影院票务管理系统")或实体关系列表时触发。不用本工具画单实体属性图。
白皛皛
未分类 community v3.1.0 5 版本 99130.4 Key: 无需
★ 1
Stars
📥 94
下载
💾 0
安装
5
版本
#latest

概述

核心工作流

  1. 确定实体名称和关系列表
  2. 首选:调用 scripts/er_design.py 生成 PNG 图片(路径:相对于 SKILL.md 所在目录的 scripts/er_design.py
  3. 验证失败回退:如果 er_design.py 运行但 exit code ≠ 0(验证不通过,通常是有环密集图交叉/重叠消不掉),自动切换到下方的"回退方案:BFS分层+力导向布局"生成图片
  4. 文件缺失回退:如果 scripts/er_design.py 不存在(单文件分发场景),同样使用回退方案
  5. 向用户展示结果图片路径
  6. 询问微调:生成完毕后,主动询问用户是否需要微调。若需要,让用户说明要修改的内容(如"把XX实体往左移"、"让XX和XX之间的连线短一些"等),根据用户指示调整后重新生成,直到用户满意。如果用户只说"微调"但未给出具体修改内容,必须追问明确,禁止自行判断并修改布局。
    • 微调时保留原图:每次微调生成新文件(如 xxx_v2.pngxxx_v3.png),不覆盖之前的版本。用户确认满意后,保留满意的版本,删除其余中间版本。
    • 首轮微调:原图 + 方格:用户表示需要微调后,首先重新生成一张与原图布局完全一致、仅叠加虚线方格的版本(如 xxx_v2.png),方便用户参照坐标描述位移。后续每轮微调都在方格版本上迭代,直到用户满意。最终满意版本去掉方格(GRID = False),删除中间版本。

路径检测与调用逻辑

import os, sys, subprocess

script_dir = os.path.dirname(os.path.abspath(__file__))  # SKILL.md 所在目录
script_path = os.path.join(script_dir, 'scripts', 'er_design.py')

if os.path.isfile(script_path):
    # 首选方案存在,尝试运行
    result = subprocess.run([sys.executable, script_path, ...], capture_output=True)
    if result.returncode == 0:
        # 成功,展示图片
        pass
    else:
        # 验证失败,走回退方案
        use_fallback()
else:
    # 脚本不存在,走回退方案
    use_fallback()

判断逻辑:先检测 er_design.py 是否存在 → 存在则运行 → exit code 0 直接用结果,非 0 走回退 → 不存在也走回退。


能力边界

能做

场景说明
------------
多实体+关系ER图实体用矩形,联系统一用菱形
自动布局检测图结构,选最优策略
基数标注1:1、1:n、m:n 自动标注
三项验证无交叉、无重叠、基数圆间距 ≥ 0.5(圆外缘到图形)

不能做

场景原因
------------
单实体属性图(含椭圆属性)用 ER-single skill
带主键/下划线本工具不标注主键
彩色/装饰风格黑白简约风格
修改已有图片每次都重新生成

1. 调用方式

python scripts/er_design.py \
  --entities "实体1,实体2,..." \
  --relations "关系1,关系2,..." \
  [--output "输出.png"] \
  [--scale 1.5] \
  [--font "SimHei"]

参数

参数说明
------------
--entities必填,逗号分隔的实体列表
--relations必填,逗号分隔的关系列表,格式 实体1:基数--动词--基数:实体2
--output输出 PNG 路径(默认 {首个实体}_ER图.png
--scale缩放比例(默认自动搜索 0.6~4.0)
--font中文字体名(默认 SimHei)
--bfs-directionBFS 分层方向:horizontal 横向(默认)/ vertical 纵向

关系格式

实体1:基数--动词--基数:实体2

基数:1nm,动词用中文。示例:

观众:1--购买--n:票务
影厅:n--播放--n:影片
学生:m--选修--n:课程

2. 自动布局策略

脚本自动检测图结构,选择最优布局:

图类型策略效果
--------------------
无环(树状结构)BFS 分层 + 行内排列优化整齐行列,零交叉,连线短
有环力导向 (Fruchterman-Reingold)自然排布,自动避让
  • BFS 分层(横向):从连接最多的实体(中心)开始 BFS,同层同 x 坐标,y 居中均分;遍历排列组合最小化交叉
  • 力导向:迭代 300 轮,引力吸引相连节点,斥力分散所有节点;自动居中缩放

检测到有环时自动切换力导向,无环时用分层保证零交叉。


3. 验证机制

保存 PNG 前自动运行三项验证,全部通过才保存:

  1. 无交叉 — 将每条关系拆为两段线段(实体→菱形、菱形→实体),逐对检查不同关系的线段是否交叉。共享端点的线段不算交叉。
  1. 无重叠 — 遍历所有图形(矩形和菱形),两两检查 x 范围和 y 范围是否同时重叠。x 或 y 任一方向不重叠即为合格。相切(边界刚好接触)也视为不合格。
  1. 基数圆间距 — 每条可见线段中点放置白色圆形(半径 = 0.2 × scale,遮挡线段用),圆形边缘到矩形边缘和菱形边缘的距离必须 ≥ 0.5。

任意一项不通过则不保存,自动尝试其他缩放比例或随机种子。


4. 强制风格

  • 黑白简约,不使用任何彩色、椭圆属性框、多余装饰
  • 实体统一用矩形,联系统一用菱形,菱形内填写动词
  • 必须标注关系基数(1、n、m),写在连接线上,数字背后用白色圆形遮挡线段
  • 无需额外标题、备注、图例或文字说明,仅输出 ER 图本身

5. 尺寸与间距

  • 矩形默认 10.0×5.5,菱形默认 9.0×5.0;基数圆到图形边缘 ≥ 0.5
  • 线段长度下限 0.6×RECT_W(硬约束,不得低于);上限 1.5×RECT_W(有重叠时可超)
  • 文字超出图形时自动增大以完整容纳
  • 图形之间禁止重叠或相切,必须留有可见间隙
  • 画布自动扩大适配,内边距 ≥ 1.5 单位
  • 图形之间禁止重叠或相切,必须留有可见间隙

6. 异常处理

返回码含义对用户说的话
---------------------------
0成功"已生成 ER 图,保存在 [路径]"
1输入错误告诉用户具体哪里错了,让用户改后重试
2缺依赖包"需要安装 Python 库,运行 pip install numpy matplotlib 就行"
其他生成失败建议减少实体数量或检查关系格式后重试

7. 逻辑自检

用户提供了完整实体和关系:直接按用户说的生成,不做修改。

用户只提供系统名称,需要你自行设计:先在大脑中推演一遍实体与业务联系,确保每条关系在业务上合乎逻辑,确认无误后再生成。


8. 回退方案:BFS分层 + 力导向布局

er_design.py 验证失败(常有环密集图交叉/重叠消不掉)或脚本不存在时,用此方案。

核心思路

  1. BFS 从中心实体(连接数最多)分层,得到 0/1/2... 层
  2. DFS 环路检测:沿实体邻接边做 DFS,发现非父节点的已访问节点即有环
  3. 无环(树状)→ 横向 BFS 分层:每层一列,层内实体纵向均分,零交叉,连线短
  4. 有环 → Fruchterman-Reingold 力导向布局:实体和菱形作为节点,迭代 300 轮,吸引/排斥力平衡后出图;支持缩放搜索(0.6~8.0)自动找最优比例
  5. 三段验证(交叉、重叠、基数圆间距)+ 线段长度硬约束,不通过则换缩放比或随机种子

完整代码模板

import math, os, sys
import numpy as np

# ══════ 用户输入 ══════
entities = ["影院", "影厅", "影片"]  # 替换为实际实体
relations = [                       # 替换为实际关系 (ei1, 动词, ei2, card1, card2)
    ("影院", "包含", "影厅", "1", "n"),
    ("影厅", "播放", "影片", "n", "n"),
]

# ══════ 输入校验 ══════
errors = []
if not entities:
    errors.append("实体列表不能为空")
seen = set()
for e in entities:
    if e in seen:
        errors.append(f"重复实体: {e}")
    seen.add(e)
if not relations:
    errors.append("关系列表不能为空")
for ei1, verb, ei2, c1, c2 in relations:
    if ei1 not in seen: errors.append(f"实体不存在: {ei1}")
    if ei2 not in seen: errors.append(f"实体不存在: {ei2}")
    if c1 not in ('1','n','m'): errors.append(f"无效基数: {c1}")
    if c2 not in ('1','n','m'): errors.append(f"无效基数: {c2}")
if errors:
    for e in errors: print(e)
    sys.exit(1)

# ══════ 依赖检查 ══════
try:
    import matplotlib
    matplotlib.use('Agg')
    import matplotlib.pyplot as plt
    import matplotlib.font_manager as fm
    from matplotlib.patches import FancyBboxPatch, Polygon
except ImportError as e:
    print(f"缺少依赖,运行: pip install numpy matplotlib")
    sys.exit(2)

# ══════ 中文字体 ══════
font_name = None
for font in ['SimHei', 'Microsoft YaHei', 'WenQuanYi Micro Hei',
             'Noto Sans CJK SC', 'PingFang SC', 'STHeiti', 'SimSun']:
    if font in {f.name for f in fm.fontManager.ttflist}:
        font_name = font; break
if font_name:
    plt.rcParams['font.family'] = [font_name]
    plt.rcParams['font.sans-serif'] = [font_name]
else:
    print("警告: 未检测到中文字体")
plt.rcParams['axes.unicode_minus'] = False

# ══════ BFS 分层 ══════
N = len(entities)
ent_idx = {e: i for i, e in enumerate(entities)}
adj = [[] for _ in range(N)]
for ei1, _, ei2, _, _ in relations:
    i, j = ent_idx[ei1], ent_idx[ei2]
    adj[i].append(j); adj[j].append(i)

# 从连接最多的实体开始BFS
center = max(range(N), key=lambda i: len(adj[i]))
layer = {center: 0}
queue = [center]
while queue:
    v = queue.pop(0)
    for u in adj[v]:
        if u not in layer:
            layer[u] = layer[v] + 1
            queue.append(u)

max_l = max(layer.values())
layers = {}
for i, l in layer.items():
    layers.setdefault(l, []).append(entities[i])

# ══════ DFS 环路检测 ══════
ent_adj = {i: set() for i in range(N)}
for ei1, _, ei2, _, _ in relations:
    i, j = ent_idx[ei1], ent_idx[ei2]
    ent_adj[i].add(j); ent_adj[j].add(i)

def has_cycle():
    visited = set()
    def dfs(v, parent):
        visited.add(v)
        for u in ent_adj[v]:
            if u not in visited:
                if dfs(u, v): return True
            elif u != parent: return True
        return False
    for v in range(N):
        if v not in visited:
            if dfs(v, -1): return True
    return False

cyclic = has_cycle()
print(f"环检测: {'有环' if cyclic else '无环'}")

# ══════ 布局选择 ══════
entity_xy = {}
entity_xy[entities[center]] = (0.0, 0.0)

if not cyclic:
    # 树状结构 → 横向BFS分层,零交叉
    # 列间距 ≥ RECT_W + 菱形宽 ≈ 9+7=16,取20留余量
    X_SP, Y_SP = 20.0, 10.0
    for lv in range(max_l + 1):
        ents = layers.get(lv, [])
        x = lv * X_SP
        n = len(ents)
        for i, ent in enumerate(ents):
            y = (i - (n - 1) / 2) * Y_SP
            entity_xy[ent] = (x, y)
else:
    # 有环图 → Fruchterman-Reingold 力导向布局
    M = len(relations)
    total = N + M
    # 构建邻接矩阵(实体↔菱形)
    adj_mat = np.zeros((total, total))
    for ri, (ei1, verb, ei2, c1, c2) in enumerate(relations):
        di = N + ri
        i, j = ent_idx[ei1], ent_idx[ei2]
        adj_mat[i, di] = adj_mat[di, i] = 1
        adj_mat[j, di] = adj_mat[di, j] = 1

    np.random.seed(42)
    pos = np.random.randn(total, 2) * 0.3
    k = 5.0 * math.sqrt(max(N, 4) / 4.0)
    T = k

    for _ in range(300):
        forces = np.zeros_like(pos)
        for i in range(total):
            for j in range(total):
                if i == j: continue
                d = np.linalg.norm(pos[i] - pos[j])
                if d < 0.01: d = 0.01
                forces[i] += (pos[i] - pos[j]) / d * (k**2 / d)
        for i in range(total):
            for j in range(total):
                if adj_mat[i, j] == 0: continue
                d = np.linalg.norm(pos[j] - pos[i])
                if d < 0.01: d = 0.01
                forces[i] += (pos[j] - pos[i]) / d * (d**2 / k)
        mag = np.linalg.norm(forces, axis=1)
        pos += forces * np.minimum(1.0, T / np.maximum(mag, 1e-10))[:, np.newaxis]
        T *= 0.92

    pos -= pos.mean(axis=0)
    pos /= np.std(pos) / 6.0

    for i, ent in enumerate(entities):
        entity_xy[ent] = (pos[i, 0], pos[i, 1])

# ══════ 缩放搜索 ══════
# 尝试多个缩放比例,通过验证后选最优
scales = [0.6, 0.8, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0]
best_scale = 1.0  # 默认
for sf in scales:
    sf_ok = True
    # 检查缩放下是否有线段太短
    for ei1, verb, ei2, c1, c2 in relations:
        x1, y1 = entity_xy[ei1]; x2, y2 = entity_xy[ei2]
        mx, my = (x1+x2)/2*sf, (y1+y2)/2*sf
        for ex, ey in [(x1*sf, y1*sf), (x2*sf, y2*sf)]:
            seg_len = math.hypot(mx - ex, my - ey) / 2  # 半段
            if seg_len < 0.6 * RECT_W * sf:  # 太短
                sf_ok = False; break
        if not sf_ok: break
    if sf_ok:
        best_scale = sf; break

print(f"缩放: {best_scale}")
for ent in entity_xy:
    entity_xy[ent] = (entity_xy[ent][0] * best_scale, entity_xy[ent][1] * best_scale)

# ══════ 手动微调(关键步骤)══════
# 力导向结果可能需要微调:对外层实体,将其移近关联的内层实体
# 写法: entity_xy["外层实体"] = (x, y)
# 根据实际图的布局效果调整坐标,这里留空由调用者填充


# ══════ 几何工具函数 ══════
def rect_point_distance(rx, ry, rw, rh, px, py):
    """计算点到矩形(含圆角近似)的最短距离,点在内部返回负值"""
    dx = max(abs(px - rx) - rw, 0.0)
    dy = max(abs(py - ry) - rh, 0.0)
    return math.hypot(dx, dy)

def diamond_point_distance(dx, dy, dw, dh, px, py):
    """计算点到菱形的最短距离"""
    dpx = abs(px - dx)
    dpy = abs(py - dy)
    if dw < 1e-10 or dh < 1e-10:
        return math.hypot(px - dx, py - dy)
    return (dpx / dw + dpy / dh - 1.0) * (dw * dh) / math.hypot(dw, dh)

# ══════ 统一修正:基数圆进入其他图形时推开该图形 ══════
# 注意:不检测菱形之间的碰撞(重叠不影响可读性,推开反而可能引入新问题)。
# 仅检测基数圆圆心到其他图形的距离,若圆边缘到图形边缘 < 0.5 则推开该图形。
R = 0.65
RECT_W, RECT_H = 9.0, 4.5
diamond_positions = {}
for ri, (ei1, verb, ei2, c1, c2) in enumerate(relations):
    x1, y1 = entity_xy[ei1]
    x2, y2 = entity_xy[ei2]
    diamond_positions[ri] = [(x1 + x2) / 2, (y1 + y2) / 2]

MIN_CIRCLE_GAP = R + 0.5
for iteration in range(50):
    violations = []
    all_graphics = []
    for ent, (ex, ey) in entity_xy.items():
        rw = max(RECT_W, len(ent) * 1.1 + 1.0) / 2
        rh = RECT_H / 2
        all_graphics.append(('rect', ex, ey, rw, rh, ent))
    for ri2 in range(len(relations)):
        mx2, my2 = diamond_positions[ri2]
        dw2 = max(7.0, len(relations[ri2][1]) * 1.2) / 2
        dh2 = max(3.5, 2.2) / 2
        all_graphics.append(('diamond', mx2, my2, dw2, dh2, ri2))

    for ri, (ei1, verb, ei2, c1, c2) in enumerate(relations):
        x1, y1 = entity_xy[ei1]
        x2, y2 = entity_xy[ei2]
        mx, my = diamond_positions[ri]
        dw = max(7.0, len(verb) * 1.2)
        dh = max(3.5, 2.2)
        for ex, ey, card, ent_name in [(x1, y1, c1, ei1), (x2, y2, c2, ei2)]:
            vx, vy = mx - ex, my - ey
            length = math.hypot(vx, vy)
            if length < 0.01: continue
            nx, ny = vx / length, vy / length
            rw = max(RECT_W, len(ent_name) * 1.1 + 1.0)
            rh = RECT_H
            tx = rw/2 / abs(nx) if abs(nx) > 1e-10 else float('inf')
            ty = rh/2 / abs(ny) if abs(ny) > 1e-10 else float('inf')
            t = min(tx, ty) * 0.92
            sx, sy = ex + nx * t, ey + ny * t
            a, b = dw / 2, dh / 2
            denom = abs(nx) / a + abs(ny) / b
            u = (1.0 / denom * 0.92) if denom > 1e-10 else 0
            dx_e, dy_e = mx - nx * u, my - ny * u
            cpx, cpy = (sx + dx_e) / 2, (sy + dy_e) / 2

            for gtype, gx, gy, ghw, ghh, gid in all_graphics:
                if gtype == 'diamond' and gid == ri: continue
                if gtype == 'rect' and gid == ent_name: continue
                if gtype == 'rect':
                    dist = rect_point_distance(gx, gy, ghw, ghh, cpx, cpy)
                else:
                    dist = diamond_point_distance(gx, gy, ghw, ghh, cpx, cpy)
                if dist < MIN_CIRCLE_GAP:
                    if gtype == 'diamond':
                        px, py = gx - cpx, gy - cpy
                        plen = math.hypot(px, py)
                        if plen < 0.01:
                            px, py = 0.0, 1.0
                            plen = 1.0
                        push = (MIN_CIRCLE_GAP - dist) + 0.3
                        violations.append((gid, px / plen * push, py / plen * push))

    if not violations:
        break

    accum = {}
    for ri, dx, dy in violations:
        if ri not in accum:
            accum[ri] = [0.0, 0.0, 0]
        accum[ri][0] += dx
        accum[ri][1] += dy
        accum[ri][2] += 1

    for ri, (tx, ty, cnt) in accum.items():
        diamond_positions[ri][0] += tx / cnt
        diamond_positions[ri][1] += ty / cnt

def validate_circle_spacing(entity_xy, relations, R, RECT_W, RECT_H):
    """检查每个基数圆边缘到所有图形边缘的距离是否 >= 0.5"""
    MIN_GAP = 0.5
    violations = []

    # 收集所有图形: (type, cx, cy, hw, hh, name)
    graphics = []
    for ent, (ex, ey) in entity_xy.items():
        rw = max(RECT_W, len(ent) * 1.1 + 1.0) / 2
        rh = RECT_H / 2
        graphics.append(('rect', ex, ey, rw, rh, ent))

    # 预计算每条关系的菱形
    diamond_info = {}
    for ri, (ei1, verb, ei2, c1, c2) in enumerate(relations):
        x1, y1 = entity_xy[ei1]
        x2, y2 = entity_xy[ei2]
        mx, my = (x1 + x2) / 2, (y1 + y2) / 2
        dw = max(7.0, len(verb) * 1.2) / 2
        dh = max(3.5, 2.2) / 2
        diamond_info[ri] = (mx, my, dw, dh, verb)
        graphics.append(('diamond', mx, my, dw, dh, verb))

    # 检查每条线段的基数圆
    for ri, (ei1, verb, ei2, c1, c2) in enumerate(relations):
        x1, y1 = entity_xy[ei1]
        x2, y2 = entity_xy[ei2]
        dmx, dmy, ddw, ddh, _ = diamond_info[ri]
        dw = ddw * 2
        dh = ddh * 2

        for ex, ey, card, ent_name in [(x1, y1, c1, ei1), (x2, y2, c2, ei2)]:
            vx, vy = dmx - ex, dmy - ey
            length = math.hypot(vx, vy)
            if length < 0.01:
                continue
            nx, ny = vx / length, vy / length

            rw_e = max(RECT_W, len(ent_name) * 1.1 + 1.0) / 2
            rh_e = RECT_H / 2
            tx = rw_e / abs(nx) if abs(nx) > 1e-10 else float('inf')
            ty = rh_e / abs(ny) if abs(ny) > 1e-10 else float('inf')
            t = min(tx, ty) * 0.92
            sx, sy = ex + nx * t, ey + ny * t

            a, b = dw / 2, dh / 2
            denom = abs(nx) / a + abs(ny) / b
            u = (1.0 / denom * 0.92) if denom > 1e-10 else 0
            d_ex, d_ey = dmx - nx * u, dmy - ny * u

            cpx, cpy = (sx + d_ex) / 2, (sy + d_ey) / 2

            # 检查线段是否够长: 半段 >= R + 0.5
            seg_len = math.hypot(d_ex - sx, d_ey - sy)
            if seg_len / 2 - R < MIN_GAP:
                violations.append(
                    f"R{ri} {ent_name}→{verb}: 线段太短({seg_len:.1f}), "
                    f"圆边距={seg_len/2 - R:.2f} < 0.5"
                )
                continue

            # 检查圆心到所有图形的距离
            for gtype, gx, gy, ghw, ghh, gname in graphics:
                if gname in (ent_name, verb):
                    continue
                if gtype == 'rect':
                    dist = rect_point_distance(gx, gy, ghw, ghh, cpx, cpy)
                else:
                    dist = diamond_point_distance(gx, gy, ghw, ghh, cpx, cpy)

                if dist < R + MIN_GAP:
                    violations.append(
                        f"R{ri} {ent_name}→{verb} 圆距{gname}: "
                        f"{dist - R:.2f} < 0.5"
                    )

    return violations


def auto_fix_diamond_positions(entity_xy, relations, R, RECT_W, RECT_H, max_iter=30):
    """自动调整菱形中点位置使其远离两端实体,满足圆间距约束"""
    MIN_GAP = 0.5
    step = 0.5

    for _ in range(max_iter):
        violations = []
        for ri, (ei1, verb, ei2, c1, c2) in enumerate(relations):
            x1, y1 = entity_xy[ei1]
            x2, y2 = entity_xy[ei2]
            mx, my = (x1 + x2) / 2, (y1 + y2) / 2
            dw = max(7.0, len(verb) * 1.2)
            dh = max(3.5, 2.2)

            for ex, ey, ent_name in [(x1, y1, ei1), (x2, y2, ei2)]:
                vx, vy = mx - ex, my - ey
                length = math.hypot(vx, vy)
                if length < 0.01:
                    continue
                nx, ny = vx / length, vy / length

                rw_e = max(RECT_W, len(ent_name) * 1.1 + 1.0) / 2
                rh_e = RECT_H / 2
                tx = rw_e / abs(nx) if abs(nx) > 1e-10 else float('inf')
                ty = rh_e / abs(ny) if abs(ny) > 1e-10 else float('inf')
                t = min(tx, ty) * 0.92

                a, b = dw / 2, dh / 2
                denom = abs(nx) / a + abs(ny) / b
                u = (1.0 / denom * 0.92) if denom > 1e-10 else 0

                seg_len = length - t - u
                if seg_len / 2 - R < MIN_GAP:
                    shortfall = 2 * (R + MIN_GAP) - seg_len
                    # 沿远离实体的方向移动菱形
                    entity_xy[ei1] = (x1, y1)  # 不修改
                    entity_xy[ei2] = (x2, y2)
                    # 把中点沿远离该实体的方向推
                    new_mx = mx + nx * shortfall * 0.6
                    new_my = my + ny * shortfall * 0.6
                    # 更新中点(临时存储在entity_xy的扩展字段)
                    if f"__dm_{ri}" not in dir():
                        pass
                    violations.append((ri, nx * shortfall * 0.6, ny * shortfall * 0.6))

        if not violations:
            break

    return validate_circle_spacing(entity_xy, relations, R, RECT_W, RECT_H)


# ══════ 绘图参数 ══════
R = 0.65  # 基数圆半径
RECT_W, RECT_H = 9.0, 4.5
FONT_SIZE = 8

# ══════ 基数圆间距验证 ══════
print("验证基数圆间距 (要求: 圆边缘到图形边缘 >= 0.5)...")
vios = validate_circle_spacing(entity_xy, relations, R, RECT_W, RECT_H)
if vios:
    print(f"  发现 {len(vios)} 项违规:")
    for v in vios:
        print(f"    - {v}")
    print("  尝试自动修正...")
    remaining = auto_fix_diamond_positions(entity_xy, relations, R, RECT_W, RECT_H)
    if remaining:
        print(f"  自动修正后仍有 {len(remaining)} 项违规:")
        for v in remaining:
            print(f"    - {v}")
        print("  请手动微调实体坐标后重试,或增大 radii 中的圆环半径")
    else:
        print("  自动修正完成,全部通过")
else:
    print("  全部通过")

# ══════ 绘图 ══════
xs = [x for x, y in entity_xy.values()]
ys = [y for x, y in entity_xy.values()]
pad = 8.0
fig, ax = plt.subplots(1, 1, figsize=(28, 26))
ax.set_xlim(min(xs) - pad, max(xs) + pad)
ax.set_ylim(min(ys) - pad, max(ys) + pad)
ax.set_aspect('equal')
ax.axis('off')

# 画实体矩形
for ent, (x, y) in entity_xy.items():
    rw = max(RECT_W, len(ent) * 1.1 + 1.0)
    rh = RECT_H
    rect = FancyBboxPatch(
        (x - rw/2, y - rh/2), rw, rh,
        boxstyle="round,pad=0.15", edgecolor='#000000', linewidth=2.0,
        facecolor='#FFFFFF', zorder=5)
    ax.add_patch(rect)
    ax.text(x, y, ent, ha='center', va='center',
            fontsize=FONT_SIZE + 3, fontweight='bold', color='#000000', zorder=6)

# 画关系菱形和连线
for ri, (ei1, verb, ei2, c1, c2) in enumerate(relations):
    x1, y1 = entity_xy[ei1]
    x2, y2 = entity_xy[ei2]

    mx, my = diamond_positions[ri]  # 使用碰撞检测后的位置

    dw = max(7.0, len(verb) * 1.2)
    dh = max(3.5, 2.2)

    diamond = Polygon([
        (mx, my + dh/2), (mx + dw/2, my), (mx, my - dh/2), (mx - dw/2, my)
    ], edgecolor='#000000', linewidth=1.5, facecolor='#FFFFFF', zorder=5)
    ax.add_patch(diamond)
    ax.text(mx, my, verb, ha='center', va='center',
            fontsize=FONT_SIZE, color='#000000', zorder=6)

    for ex, ey, card, ent_name in [(x1, y1, c1, ei1), (x2, y2, c2, ei2)]:
        vx, vy = mx - ex, my - ey
        length = math.hypot(vx, vy)
        if length < 0.01:
            continue
        nx, ny = vx / length, vy / length

        rw = max(RECT_W, len(ent_name) * 1.1 + 1.0)
        rh = RECT_H
        tx = rw/2 / abs(nx) if abs(nx) > 1e-10 else float('inf')
        ty = rh/2 / abs(ny) if abs(ny) > 1e-10 else float('inf')
        t = min(tx, ty) * 0.92
        sx, sy = ex + nx * t, ey + ny * t

        a, b = dw / 2, dh / 2
        denom = abs(nx) / a + abs(ny) / b
        u = (1.0 / denom * 0.92) if denom > 1e-10 else 0
        dx_e, dy_e = mx - nx * u, my - ny * u

        ax.plot([sx, dx_e], [sy, dy_e], color='#000000', linewidth=1.2, zorder=3)

        cpx, cpy = (sx + dx_e) / 2, (sy + dy_e) / 2
        circle = plt.Circle((cpx, cpy), R, facecolor='white', linewidth=0,
                            edgecolor='none', zorder=7)
        ax.add_patch(circle)
        ax.text(cpx, cpy, card, ha='center', va='center',
                fontsize=FONT_SIZE + 1, color='#000000', zorder=8)

output = f"{entities[0]}等_ER图.png"
plt.tight_layout(pad=1.0)
fig.savefig(output, dpi=200, bbox_inches='tight', facecolor='#FFFFFF', edgecolor='none')
plt.close(fig)
print(f"成功: {output}")
print(f"完整路径: {os.path.abspath(output)}")

关键步骤:手动微调

力导向布局后,节点位置由物理模拟决定,可能出现以下需要微调的情况:

  • 某个实体离它关联的实体太远,连线过长 → 将该实体向关联实体方向移动
  • 两个菱形位置太近或重叠 → 手动拉开距离(内置碰撞检测也会自动推开,但复杂情况可能需要手动介入)
  • 某条线段太短,基数圆放不下 → 增大缩放比例或手动拉开两端实体距离

微调方式:直接在 entity_xy["实体名"] = (x, y) 中设置坐标,重新运行看效果,反复调整。

菱形碰撞检测

多条关系的中点在几何上可能重叠(如购物车与用户的垂直中点和订单与商品的水平中点恰好都是 (0, 12)),导致两个菱形叠在一起,看起来像菱形连着菱形。回退方案在手动微调后自动运行碰撞检测:两个菱形中心距离 < 8.0 时,沿连线方向互相推开,最多迭代 20 轮。绘图时使用碰撞检测后的 diamond_positions 而非原始中点。

基数圆间距验证(与首选方案一致)

回退方案同样执行 基数圆到图形边缘 ≥ 0.5 的验证。验证函数 validate_circle_spacing() 检查:

  1. 线段长度:基数圆所在线段的半长 − R ≥ 0.5(即圆不碰到两端的矩形和菱形)
  2. 图形间距:圆心到所有不相关图形(矩形/菱形)的距离 − R ≥ 0.5(即圆不碰到其他关系的图形)

验证不通过时:

  • 先调用 auto_fix_diamond_positions() 自动推远菱形中点
  • 仍有违规则打印具体违规项,提示增大 radii 圆环半径或手动微调实体坐标

回退模式错误处理

问题处理方式
---------------
ImportError告诉用户 pip install numpy matplotlib
中文字体缺失警告但继续(中文变方框),建议安装字体
绘图异常try/except 包裹,缩小尺寸或简化参数重试
全部失败输出实体和关系列表,建议用户手动绘制

风格要求(与首选方案一致)

  • 实体用矩形,联系统一用菱形
  • 连接线只连图形边缘,不穿过内部
  • 基数写在线段中点,白色圆遮挡线条
  • 颜色用十六进制 '#000000',禁止用颜色名称
  • 不标主键、无标题、无图例
  • 完成后删除临时 .py 文件

版本历史

共 5 个版本

  • v3.1.0 新功能 微调+方格工作流(首轮叠加虚线方格、迭代生成新文件、满意后去方格) 文档修正 回退方案描述从"极坐标环形"统一为"力导向",手动微调说明适配新布局 Bug 修复 模板 ri 未定义、函数定义顺序错误 当前
    2026-05-26 20:15 安全 安全
  • v1.1.2 重新设计了排布的规则
    2026-05-24 11:25 安全 安全
  • v1.1.1 为README.md加了一些规范性。
    2026-05-22 23:57 安全 安全
  • v1.1.0 更新了排版规则: 实体少(≤6)的时候手调坐标,排得对称整齐。实体多(≥7)的时候用 MDS 算法自动算位置,省得来回试错。 切换阈值定在 6~7,因为之前教务系统 6 个实体手工排得很清爽,医院系统 8 个手工排了 3 版才过,而 MDS 一版就过了。
    2026-05-22 23:34 安全 安全
  • v1.0.0 Initial release
    2026-05-21 21:47 安全 安全

安全检测

腾讯云安全 (Keen)

安全,无风险
查看报告

腾讯云安全 (Sanbu)

安全,无风险
查看报告

🔗 相关推荐

ai-intelligence

ontology

oswalpalash
类型化知识图谱,用于结构化智能体记忆与可组合技能。支持创建/查询实体(人员、项目、任务、事件、文档)及关联...
★ 714 📥 243,986
developer-tools

Github

steipete
使用 `gh` CLI 与 GitHub 交互,通过 `gh issue`、`gh pr`、`gh run` 和 `gh api` 管理议题、PR、CI 运行及高级查询。
★ 670 📥 324,317
security-compliance

Skill Vetter

spclaudehome
AI智能体技能安全预审工具。安装ClawdHub、GitHub等来源技能前,检查风险信号、权限范围及可疑模式。
★ 1,217 📥 266,660