惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Help Net Security
Help Net Security
Latest news
Latest news
G
GRAHAM CLULEY
C
CXSECURITY Database RSS Feed - CXSecurity.com
T
The Exploit Database - CXSecurity.com
WordPress大学
WordPress大学
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Jina AI
Jina AI
U
Unit 42
人人都是产品经理
人人都是产品经理
小众软件
小众软件
Microsoft Security Blog
Microsoft Security Blog
C
CERT Recently Published Vulnerability Notes
V
Vulnerabilities – Threatpost
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
NISL@THU
NISL@THU
AWS News Blog
AWS News Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
C
Cisco Blogs
Google DeepMind News
Google DeepMind News
阮一峰的网络日志
阮一峰的网络日志
Project Zero
Project Zero
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Know Your Adversary
Know Your Adversary
Cyberwarzone
Cyberwarzone
Y
Y Combinator Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
L
LINUX DO - 热门话题
The Hacker News
The Hacker News
Application and Cybersecurity Blog
Application and Cybersecurity Blog
GbyAI
GbyAI
酷 壳 – CoolShell
酷 壳 – CoolShell
Blog — PlanetScale
Blog — PlanetScale
T
Threat Research - Cisco Blogs
The GitHub Blog
The GitHub Blog
N
News and Events Feed by Topic
N
News | PayPal Newsroom
Attack and Defense Labs
Attack and Defense Labs
Cloudbric
Cloudbric
Scott Helme
Scott Helme
J
Java Code Geeks
H
Hacker News: Front Page
N
News and Events Feed by Topic
Recorded Future
Recorded Future
Martin Fowler
Martin Fowler
W
WeLiveSecurity
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
量子位
有赞技术团队
有赞技术团队

姓王者的博客

Linux用户Secure Boot自主维护指南 | 姓王者的博客 MAD Bugs 已经开始——关于信息安全的军备竞赛 | 姓王者的博客 解决钉钉Dingtalk无法在Linux新版内核上启动问题-修复可执行栈错误 | 姓王者的博客 突发:GitHub 正遭受大规模 Issue 赌博广告轰炸 | 姓王者的博客 Ubuntu26.04-beta体验:坚毅浣熊! | 姓王者的博客 fakeclaw装作龙虾发贴吧 | 姓王者的博客 找回12年前的QQ记忆 | 姓王者的博客 在Linux上玩Flash网页游戏-洛克王国 | 姓王者的博客 Copilot将使用交互数据来训练 | 姓王者的博客 重要通知-请更新我的GPG公钥 | 姓王者的博客 为了自由Android | 姓王者的博客 GPL"2,3"事 | 姓王者的博客 短文-对VitePlus的一点🤏小贡献 | 姓王者的博客 Bing收录没了?亲测有效的快速恢复指南 | 姓王者的博客 解决桌面设备二维码快速识别的工具-ClipQR | 姓王者的博客 解决 Nautilus 自定义终端插件安装依赖问题 | 姓王者的博客 OpenClaw 该熄火了 | 姓王者的博客 Vite8 - 统一的基建开始 | 姓王者的博客 Astro 6 推出啦 | 姓王者的博客 ubuntu的openvpn异常暂停推送更新 | 姓王者的博客 Ubuntu 24.04 安装 Win10 虚拟机 | 姓王者的博客 ESA-后记:热爱阿里云 | 姓王者的博客 Moonbit 0.8.0 重大发布,我也要改一下我的包 | 姓王者的博客 ESA Pages 边缘开发大赛获奖 | 姓王者的博客 Astro: 优化katex,mermaid和灯箱使用 | 姓王者的博客 从edgeone迁移到esa | 姓王者的博客 出租人类:AI时代的荒诞与真实 | 姓王者的博客 Astro 5.17构建性能优化实践:从18s到13s | 姓王者的博客 Moonbit License Checker 开发使用 | 姓王者的博客 Stalux Astro博客主题自荐 | 姓王者的博客 把Hexo永久链接迁移到Astro | 姓王者的博客 再见👋 LeanCloud | 姓王者的博客 2025年终总结 | 姓王者的博客 许可合规-fancybox | 姓王者的博客 博客主题的软著下来了 | 姓王者的博客 友链图谱 - 汇聚千丝万缕的联系 | 姓王者的博客 为什么我推荐你使用GPG来加密你的邮件 | 姓王者的博客 2025第三方客户端登录东北大学邮箱 | 姓王者的博客 好久没更新了,过去与未来 | 姓王者的博客 1024 重要的日子 | 姓王者的博客 再也不见Windows10 | 姓王者的博客 偷梁换柱,解决Ubuntu24.04安装Packet Tracer缺失依赖问题 | 姓王者的博客 中秋-来试试Moonbit吧 | 姓王者的博客 Obsidian使用体验 | 姓王者的博客 猪猪侠·一只老猪的逆袭 | 姓王者的博客 国庆日纪念 | 姓王者的博客 GNU 42周年,AI时代的自由精神 | 姓王者的博客 解决Linux上启动游戏总是默认English的情况 | 姓王者的博客 7x24:运维使命 | 姓王者的博客 Tauri2.x实现系统菜单导航Vue路由 | 姓王者的博客 计算机图形学-基本图形生成算法 | 姓王者的博客 数据库原理-关系数据 | 姓王者的博客 数据库原理-设计技巧 | 姓王者的博客 数据库原理E-R模型 | 姓王者的博客 旧忆 - 我曾玩过的游戏 | 姓王者的博客 再谈自由软件 | 姓王者的博客 可能解决Tauri多窗口应用阻塞问题 | 姓王者的博客 Xingwangzhe! Z-Library We miss you and we need your help | 姓王者的博客 计算机组成原理第二章 - 定点数与浮点数 | 姓王者的博客 计算机组成原理第一章 | 姓王者的博客 不小心写死循环窗口弹出了 | 姓王者的博客 美化Grub界面 | 姓王者的博客 计算机图形学-图形的表示与数据结构 | 姓王者的博客 计算机图形学绪论 | 姓王者的博客 为什么说,大学教育与社会脱节 | 姓王者的博客 VSCode Remote 远程连接服务器记录 | 姓王者的博客 解决Tauri2.x拖拽事件问题 | 姓王者的博客 新学期第一课《计算机图形学》报告 | 姓王者的博客 Tauri在GNOME46+上通知无效的临时解决方法 | 姓王者的博客 窃文者:未经授权转载我文章 | 姓王者的博客 GPG公钥分享文化 | 姓王者的博客 解决在ubuntu上,打包vscode插件问题 | 姓王者的博客 伪造squaremap的玩家显示 | 姓王者的博客 爆,沉浸式翻译泄露敏感信息 | 姓王者的博客 读书:《Free as in Freedom》——若为自由故 | 姓王者的博客 首页文章列表懒加载优化 | 姓王者的博客 Ubuntu 24.04 安装 Vivado 2018.3 | 姓王者的博客 腾讯Edgeone免费版体验 | 姓王者的博客 在 Ubuntu 上实现 Thetis FIDO U2F 密钥登录 | 姓王者的博客 Thetis物理密钥,为什么我们应该使用物理密钥 | 姓王者的博客 高考生过来看!教你精准转换录取位次! | 姓王者的博客 ubuntu无法访问windows磁盘问题 | 姓王者的博客 收信有感,防范钓鱼邮件 | 姓王者的博客 自由不止软件-记录一次zlib上传书籍 | 姓王者的博客 时隔两年,通关夺命邮差2 | 姓王者的博客 博客一周年了,竟然坚持了下来 | 姓王者的博客 Minecraft大电影:不建不散! | 姓王者的博客 是时候了解docker了! | 姓王者的博客 编译原理:LL(1)文法 | 姓王者的博客 编译原理:文法转换 | 姓王者的博客 离散数学:子群的陪集及拉格朗日定理 | 姓王者的博客 离散数学:半群,独异点 | 姓王者的博客 《人工智能生成合成内容标识办法》与个人博客--我们应该做什么? | 姓王者的博客 通识学习:形式语言与自动机,布尔代数与数进制 | 姓王者的博客 离散数学:代数系统(一) | 姓王者的博客 Webmapview:一个我的世界内置网页地图浏览Fabric模组 | 姓王者的博客 海岛机器人农场试玩 | 姓王者的博客 正则表达式学习 | 姓王者的博客 抓取个人博客文章目录到github主页 | 姓王者的博客 制作github贪吃蛇贡献图 | 姓王者的博客
chen-er 专为Chen式ER图打造的npm包 | 姓王者的博客
作者:xingwangzhe · 2025-12-03 · via 姓王者的博客

🕒 阅读时间:4 分钟 📝 字数:1474 👀 阅读量: Loading...

chenER图演示

前言

现代 Web 的各种图形库基本上都默认存在 ER 图 渲染,尤其是 Mermaid 这种“文本到图形”更是不例外,但遗憾的是,这些图形库完全都是按照 Crow’s Foot 方法

Crow’s Foot 方法(也称 “乌鸦脚表示法”)是一种用于数据库设计的实体 - 关系 (ER) 图可视化表示方法,以其表示 “多” 关系的独特三分支符号(类似鸟爪)得名。它是数据库设计中最流行的 ER 表示法之一,广泛应用于软件工程、信息工程和数据库建模领域。

来写的,但学校是按照 Chen’s Notation,也就是经典的 菱形关系、椭圆属性、矩形实体

Chen 表示法(也称 “陈式 ER 表示法”)是由华裔计算机科学家陈品山(Peter Pin-Shan Chen) 于 1976 年在《ACM Transactions on Database Systems》发表的《The Entity-Relationship Model—Toward a Unified View of Data》中首次提出的实体 - 关系(ER)模型可视化规范,是原始 ER 模型的官方表示法,也是数据库建模、软件工程教学中最经典的 ER 可视化范式。

个人来讲,ER 图 表示法是我在学校的数据库课中学来的。为了方便自己快速从文本绘制 ER 图(就像 Mermaid 那样)而不是通过第三方工具手动拖拽拼凑那样繁琐麻烦

那么作为计算机学生,发现网上缺少这样的实现,不免心痒,于是就诞生了下面的 npm 包

chen-er

github/chen-er: https://github.com/xingwangzhe/chen-er/

npm/chen-er: https://www.npmjs.com/package/chen-er

在线体验!!!: https://codesandbox.io/p/sandbox/m248ws

chen-er 是基于 PeggyECharts 开发的、从文本转到 ER 图npm 包,其中,Peggy 用来将文本进行解析/序列化,ECharts 用来实现图形渲染与自动布局

技术细节

为什么选择 Peggy

这点要感谢 AI 的推荐,这个库看起来没什么人用,但我敢说这绝对是宝藏:它对语法的解析绝对比自己手撸正则要好很多!!!

我们可以自定义**“宏”**,来方便复用一些常用的方法

// Peggy 语法通用规则库

_ // 可选空白 + 注释

= (comment / [ \t\n\r])*

__ // 至少一个空白字符

= [ \t\n\r]+

// 行内空白(不含换行)

hws = [ \t]*

// 跨平台换行符

newline

= "\r\n" / "\n" / "\r"

// 标识符(支持 Unicode 字母和数字)

Identifier

= id:$[^ \t\n\r{}():\"#\-]+ &{ return /^[\p{L}_][\p{L}\p{N}_]*$/u.test(id); } { return id; }

// 行注释(不吞掉换行)

comment

= "#" [^\n\r]* { return null; }

接下来我们可以定义实体与关系。显然,只要我们学过 C++Java 这种 OOP 范式的编程语言,自然知道应该如何定义它,一个简单的格式就定好了

entity Customer {

customer_id PK # 主键下划线用PK标记

name

email

phone

}

接下来就开始定义了,当你看到下面的 Peggy 代码,你会发现,这个定义非常符合编程直觉;即使你从未学过编译原理或者正则表达, 也能看懂很多!这正是 Peggy 的魅力所在!!!

// 引用公共规则

import { Identifier, hws, newline, _ , __, comment } from "./common.peggy"

// start // 解析入口

// = _ entities:entity+ _ { return entities; }

// 注释在 common.peggy 中

entity // 解析实体定义(允许前置空白)

= _ "entity" __ name:Identifier _ "{" _ fields:fieldBlock _ "}" {

return { type: "entity", name: name, fields: fields };

}

// 字段块:零到多行字段,每行以换行结束

fieldBlock

= fieldLine*

fieldLine // 解析多行字段定义(行首至少一个空白,避免空匹配;行尾可注释)

= hws f:field hws comment? hws newline { return f; }

field // 解析字段定义(行内不吞换行)

= name:Identifier hws pk:("PK")? {

return { type: "field", name, pk: pk !== null };

}

类似的对于关系,我们可以这么定义

rel Customer -- Order (1:n) "places" # 客户下订单

rel Customer -- Address (m:n) "uses" # 客户使用地址

rel Order -- Address (1:1) "shipped to" # 订单发货地址

相应的代码实现

// 引用公共规则

import { Identifier, hws, newline, _ , __, comment } from "./common.peggy"

// start // 解析入口

// = _ rels:rel+ _ { return rels; }

// 关系定义:rel <实体1> -- <实体2> : (基数) "<关系名>"

rel

= _ "rel" __ left:Identifier __ "--" __ right:Identifier _ ":" _ "(" card:Cardinality ")" _ name:QuotedString _ comment? newline? {

return { type: "relation", left, right, cardinality: card, name };

}

// 基数:如 1:n, m:n, 1:1

Cardinality

= chars:[^)]* { return chars.join("").trim(); }

// 双引号字符串

QuotedString

= '"' chars:[^"]* '"' { return chars.join(""); }

这两个重点写好了,那么汇总一下

// 引用公共规则

import { _ } from "./common.peggy"

// 引用实体规则

import { entity } from "./entities.peggy"

// 引用关系规则

import { rel } from "./sample.peggy"

start // 解析入口:支持实体和关系混合定义

= _ elements:(entity / rel)+ _ {

return elements;

}

至此,语法解析的部分基本结束。同时,这些 Peggy 编译后输出的 JS 代码会自动输出序列化的数据,接下来将在 ECharts 中使用。

ECharts 渲染

首先简单定义一下类型

/**

* 字段定义

*/

export interface ERField {

type: "field";

name: string;

pk: boolean;

}

/**

* 实体定义

*/

export interface EREntity {

type: "entity";

name: string;

fields: ERField[];

}

/**

* 关系定义

*/

export interface ERRelation {

type: "relation";

left: string;

right: string;

cardinality: string;

name: string;

}

/**

* ER 图 JSON 节点类型

*/

export type ERJson = EREntity | ERRelation;

“桥接”点,方便引入 Peggy 编译产生的 JS 解析器和序列化功能

// @ts-ignore 使用 peggy 生成的解析器(ESM,命名导出 parse)

// 生成脚本见 package.json -> scripts.pg

import { parse as peggyParse } from "../grammar/peggy/all-parser.js";

import type { ERJson } from "./type";

/**

* 解析 ER 图文本为 AST

* @param input 多行 ER 图定义文本

* @returns 解析后的 AST 数组

*/

export function parseERSchema(input: string): ERJson[] {

try {

return peggyParse(input) as ERJson[];

} catch (e: any) {

console.error("解析失败:", e?.message, "\n位置:", e?.location);

throw e;

}

}

/**

* 解析 ER 图文本并输出 JSON

* @param input 多行 ER 图定义文本

* @returns JSON 字符串

*/

export function parseERSchemaToJSON(input: string): string {

return JSON.stringify(parseERSchema(input), null, 2);

}

为了方便叙述,下面代码改成高度概括的版本

仅保留三个关键步骤——解析parseERSchema)→ 映射(实体/属性/关系三类节点与连边)→ 渲染(ECharts Graph + 力导向布局)。形状遵循 Chen 表示法:实体=矩形,属性=椭圆(主键红色描边),关系=菱形;基数标注到边标签上。通过 chenERRbyClass/chenERRbyId 即可把容器内的 ER 文本 直接渲染为可交互图。

import * as echarts from "echarts/core";

import { GraphChart } from "echarts/charts";

import { TooltipComponent } from "echarts/components";

import { CanvasRenderer } from "echarts/renderers";

import type { ComposeOption } from "echarts/core";

import type { GraphSeriesOption } from "echarts/charts";

import { parseERSchema } from "./main";

// 仅注册必需组件(图 + 提示 + 渲染器)

echarts.use([GraphChart, TooltipComponent, CanvasRenderer]);

type ECOption = ComposeOption<GraphSeriesOption>;

// 椭圆属性节点(Chen)

const ELLIPSE = "path://M50 0 A50 50 0 1 1 49.999 0 Z";

// AST -> Graph(节点、连边)

function astToGraph(ast: any[]) {

const nodes: any[] = [];

const edges: any[] = [];

for (const it of ast) {

if (it.type === "entity") {

const id = it.name;

nodes.push({

id,

name: id,

symbol: "rect",

category: "entity",

symbolSize: [Math.max(140, id.length * 14 + 40), 48],

label: { show: true },

attrs: (it.fields || []).map((f: any) => ({ name: f.name, pk: !!f.pk })),

});

for (const f of it.fields || []) {

const aid = `${id}.${f.name}`;

nodes.push({

id: aid,

name: f.name,

symbol: ELLIPSE,

symbolKeepAspect: false,

symbolSize: [Math.max(90, f.name.length * 16), 36],

category: "attribute",

label: { show: true },

itemStyle: {

borderColor: f.pk ? "#d62728" : "#5470c6",

borderWidth: f.pk ? 3 : 1,

},

});

edges.push({ source: aid, target: id });

}

} else if (it.type === "relation") {

const rid = `rel:${it.name}-${it.left}-${it.right}`;

const [l = "", r = ""] = (it.cardinality || "").split(":");

nodes.push({

id: rid,

name: it.name,

symbol: "diamond",

symbolSize: [110, 60],

category: "relation",

label: { show: true },

});

edges.push({ source: rid, target: it.left, name: l });

edges.push({ source: rid, target: it.right, name: r });

}

}

return { nodes, edges };

}

// 批量渲染指定类名容器

export function renderChenER(cls = "chenER") {

const list = document.getElementsByClassName(cls) as HTMLCollectionOf<HTMLElement>;

for (let i = 0; i < list.length; i++) {

const el = list[i];

const { nodes, edges } = astToGraph(parseERSchema(el.textContent || ""));

el.innerHTML = "";

const chart = echarts.init(el);

const option: ECOption = {

series: [

{

type: "graph",

layout: "force",

roam: true,

data: nodes,

links: edges,

categories: [{ name: "entity" }, { name: "relation" }, { name: "attribute" }],

label: { show: true, position: "inside" },

edgeLabel: { show: true, formatter: (p: any) => p?.data?.name ?? "" },

edgeSymbol: ["none", "none"],

lineStyle: { color: "#888", curveness: 0.2 },

force: { repulsion: 600, edgeLength: 140, friction: 0.2 },

tooltip: {

show: true,

formatter: (p: any) => {

if (p.dataType === "node" && p?.data?.category === "relation") {

return p.data.name || "";

}

if (p.dataType === "node" && p?.data?.category === "entity") {

const d = p.data as any;

const attrs = (d.attrs || []).map((a: any) =>

a.pk ? `*${a.name}` : a.name,

);

return [d.name, ...attrs].join("<br/>");

}

return p.name || "";

},

},

},

],

};

chart.setOption(option);

addEventListener("resize", () => chart.resize());

}

}

export const chenERRbyClass = renderChenER;

export function chenERRbyId(id: string) {

const el = document.getElementById(id) as HTMLElement | null;

if (!el) throw new Error(`Container #${id} not found`);

el.classList.add("chenER");

renderChenER("chenER");

}

还等什么,现在就去试试吧!

https://codesandbox.io/p/sandbox/m248ws