@clawhub-ftois-9b5ecc8dac
将大模型生成或修改的内容,按照对应中文文档排版国家标准与行业规范, 输出为格式严格正确的 Word (.docx) 文档。 支持 19 种文档类型,依据《Word文档类型与风格指南》全量实现所有排版规范, 包括党政公文、商业文档、学术论文、技术文档、医疗文档、营销策划、 法律文书、金融报告、工程文档等。 v4.1...
---
name: docx-output
version: 4.1.0
description: >
将大模型生成或修改的内容,按照对应中文文档排版国家标准与行业规范,
输出为格式严格正确的 Word (.docx) 文档。
支持 19 种文档类型,依据《Word文档类型与风格指南》全量实现所有排版规范,
包括党政公文、商业文档、学术论文、技术文档、医疗文档、营销策划、
法律文书、金融报告、工程文档等。
v4.1 架构:Markdown 语义层 + Pandoc 渲染引擎 + 14 个专属模板,
从根本上解决编号累加、标题污染、代码缩进、首页页码等顽固问题。
跨平台兼容 Windows / macOS / Linux,自动适配系统字体。
触发条件:用户要求"导出 Word"、"生成 docx"、"输出文档"、"按规范排版"等。
author: OpenClaw User
license: MIT
tags: [docx, word, pandoc, markdown, chinese-standard, cross-platform]
platform: [windows, macos, linux]
tools_required: [bash, write_file]
dependencies:
system: [python3, pandoc]
python: [python-docx>=1.0]
---
# DOCX 规范输出 Skill v4.1
## 架构说明
```
AI → Markdown(语义层)
→ Pandoc(渲染引擎)+ reference.docx 模板(14个,存储排版规范)
→ .docx 输出
三大保障:
编号正确 — Pandoc 对每个独立列表块天然从1计数,无需 hack
样式稳定 — 字体/字号/行距在模板中预定义,Word 原生渲染
缩进保留 — Pandoc fenced code block 完整保留所有空白
```
---
## 文件结构
```
docx-output/
├── SKILL.md ← 本文件
├── converter.py ← 核心转换器
├── build_templates.py ← 模板构建脚本(首次或修改规范时运行)
└── templates/ ← 14 个 reference.docx 模板
├── template_GOV_DOC.docx
├── template_GOV_JUDICIAL.docx
├── template_BUSINESS_CONTRACT.docx
├── template_BUSINESS_TENDER.docx
├── template_BUSINESS_PLAN.docx
├── template_ACADEMIC_PAPER.docx
├── template_ACADEMIC_LESSON.docx
├── template_TECH_MANUAL.docx
├── template_MEDICAL_DOC.docx
├── template_MARKETING_DOC.docx
├── template_LEGAL_DOC.docx
├── template_FINANCE_REPORT.docx
├── template_ENGINEERING_DOC.docx
└── template_GENERAL_DOC.docx
```
---
## 第一步:环境检查与初始化
```bash
pandoc --version # 需要 Pandoc >= 2.0
# 安装:macOS: brew install pandoc | Ubuntu: sudo apt install pandoc
# Windows: choco install pandoc 或 https://pandoc.org
python3 -c "import docx" || pip install python-docx --break-system-packages
# 首次部署,构建所有模板
python3 /path/to/skill/build_templates.py
```
---
## 第二步:文档类型选择(19种)
| doc_type | 场景 | 模板 | 序号风格 |
|---|---|---|---|
| `GOV_DOC` | 党政机关公文(通知/报告/决定) | GOV_DOC | 一、(一)1.(1)四级 |
| `GOV_JUDICIAL` | 司法文书 | GOV_JUDICIAL | 一、(一)1. |
| `GOV_DIPLOMATIC` | 外交照会 | GOV_JUDICIAL | 一、(一)1. |
| `BUSINESS_CONTRACT` | 合同/协议 | BUSINESS_CONTRACT | 一、(一)1. |
| `BUSINESS_TENDER` | 标书/投标文件 | BUSINESS_TENDER | 一、(一)1. |
| `BUSINESS_PLAN` | 商业计划书/创业计划书 | BUSINESS_PLAN | 一、(一)1. |
| `ACADEMIC_PAPER` | 学术论文/研究报告 | ACADEMIC_PAPER | 一、(一)1. |
| `ACADEMIC_LESSON` | 教案/教学大纲 | ACADEMIC_LESSON | 一、(一)1. |
| `TECH_SRS` | 软件需求规格说明书 | TECH_MANUAL | 1. 1.1 1.1.1 |
| `TECH_MANUAL` | 用户手册/操作指南 | TECH_MANUAL | 1. 1.1 1.1.1 |
| `MEDICAL_RECORD` | 病历/医疗记录 | MEDICAL_DOC | 一、(一)1. |
| `MEDICAL_DRUG` | 药品说明书 | MEDICAL_DOC | 【模块名称】 |
| `MARKETING_PLAN` | 营销策划案 | MARKETING_DOC | 一、(一)1. |
| `MARKETING_ANALYSIS` | 市场分析报告 | MARKETING_DOC | 一、(一)1. |
| `LEGAL_OPINION` | 法律意见书 | LEGAL_DOC | 一、(一)1.(1)四级 |
| `LEGAL_LITIGATION` | 律师文书/诉状 | LEGAL_DOC | 一、(一)1.(1)四级 |
| `FINANCE_REPORT` | 金融/财务报告 | FINANCE_REPORT | 一、(一)1. |
| `ENGINEERING_DOC` | 工程文档/项目方案 | ENGINEERING_DOC | 1. 1.1 1. |
| `GENERAL_DOC` | 通用商务文档(兜底) | GENERAL_DOC | 一、(一)1. |
---
## 第三步:各类型排版规范速查
### GOV_DOC — 党政机关公文(GB/T 9704-2012)
```
页边距:上3.7 下3.5 左2.8 右2.6(cm)
标题:二号宋体(模拟小标宋),居中,不加粗
正文:三号仿宋,固定行距28磅(每页22行/每行28字)
序号:一、(一)1.(1)四级,第四级用 h4()
英文:Times New Roman
页码:首页不显示(模板已设置)
```
### BUSINESS_TENDER — 标书/投标文件
```
页边距:上2.6 下2.2 左右2.5(cm)
封面/目录:三号加粗微软雅黑(本类型专属字体)
正文:小四号宋体,1.5倍行距
英文:Calibri
```
### ACADEMIC_PAPER — 学术论文
```
页边距:上下2.5 左3.1 右3.2(cm)
标题:三号仿宋,前空三行后空两行,居中
摘要:小四号仿宋,1.25倍,左右缩进2字符 → abstract()
关键词:小四号仿宋,另起一行 → abstract(keywords=...)
一级标题:四号黑体,前后空一行
二级标题:小四号楷体
正文:五号宋体,1.25倍,首行缩进2字符
引文脚注:小五号,悬挂缩进1字符 → footnote_ref()
图题:五号仿宋,置图下方,自动编号 → figure_caption()
表题:五号仿宋,置表上方,自动编号 → table(caption=...)
英文:Times New Roman
```
### TECH_MANUAL — 用户手册/操作指南
```
页边距:上下2.5 左右2.5(cm)
封面:四号黑体,居中
一级标题:小四号黑体,1. 格式,左对齐
二级标题:小四号宋体加粗,1.1 格式
步骤说明:项目符号列表 → bullet()
警告提示:红色加粗 ⚠ → warning()
代码块:Courier New,灰底,保留缩进 → code_block(lang="python")
正文:五号宋体,不缩进
英文:Calibri
```
### MEDICAL_DRUG — 药品说明书
```
模块标题:【药品名称】【作用与用途】等 → module_heading()
警示语:黑体加粗红色突出 → warning()
正文:小四号宋体,1.5倍行距
英文:Times New Roman
```
### LEGAL_DOC — 法律文书
```
页边距:上下2.54 左右3.17(cm)
序号:一、(一)1.(1)严格四级
引用法条:楷体,加「」引号 → quote()
落款:仿宋四号居中 → signature_block()
英文:Times New Roman
```
### FINANCE_REPORT — 金融/财务报告
```
正文:四号宋体,固定行距28磅
数据表格:关键数据加粗 → table(bold_rows=[0,1,...])
配色:主色深蓝#2F5496,辅色浅灰,点缀橙色
英文:Times New Roman
```
---
## 第四步:生成脚本模板
```python
"""generate_doc.py"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from converter import DocxConverter
c = DocxConverter("TECH_MANUAL") # ← 替换为实际类型
# 标题
c.title("文档主标题")
c.subtitle("副标题(可选)")
# 标题层级(auto_number=True 智能防双重编号)
c.h1("章节一") # → "1. 章节一" 或 "一、章节一"(按类型)
c.h2("子章节") # → "1.1 子章节" 或 "(一) 子章节"
c.h3("三级标题") # → "1.1.1" 或 "1."
c.h4("四级标题") # → "(1) 四级标题"(公文/法律类型)
# 已含序号不重复编号
c.h1("一、已有序号章节") # 检测到已有序号,直接输出不加前缀
# 正文(支持 Markdown 内联)
c.body("正文段落,支持 **加粗** *斜体* `代码`。")
# 编号列表(换章节自动重置)
c.numbered("第一项") # → 1
c.numbered("第二项") # → 2
c.h1("新章节")
c.numbered("又一项") # → 1(自动重置!)
c.bullet("项目符号一")
c.bullet(" - 子项", level=1)
# === 专属 API ===
# 学术摘要
c.abstract("摘要内容...", keywords="关键词1;关键词2")
# 脚注引用
c.footnote_ref("正文内容", "脚注:作者.书名.出版社,年份.")
# 药品说明书模块标题
c.module_heading("药品名称")
# 警告提示
c.warning("此操作不可逆,请谨慎执行!")
# 图题(自动编号,置图下方)
c.figure_caption("系统架构示意图")
# 表格(表题置上,自动编号;数值列自动右对齐)
c.table(
headers=["指标", "方法A", "数值"],
rows=[["准确率","92.3%","0.923"],["召回率","88.7%","0.887"]],
caption="性能对比",
bold_rows=[0] # 第一行加粗(金融报告关键数据)
)
# 引用/法条
c.quote("《民法典》第X条:相关法律条文内容。")
# 落款
c.signature_block("甲方:___________", "日期:___年___月___日")
# 代码块(缩进完整保留)
c.code_block("""import torch
class Model(torch.nn.Module):
def __init__(self):
super().__init__()
self.fc = torch.nn.Linear(10, 1)""", lang="python")
# 目录条目
c.toc_entry("一、章节一", "1")
c.toc_entry("1.1 子章节", "2", level=2)
# 其他
c.highlight("重点内容") # 点缀色高亮
c.divider() # 分隔线
c.page_break() # 分页符
c.spacer(2) # 空行
c.save("/mnt/user-data/outputs/输出文件名.docx")
```
---
## API 速查表
| 方法 | 说明 | 关键参数 |
|------|------|---------|
| `DocxConverter(doc_type)` | 创建转换器 | 19种类型之一 |
| `title(text)` | 主标题 | — |
| `subtitle(text)` | 副标题 | — |
| `h1/h2/h3(text, auto_number)` | 一二三级标题 | `auto_number=True` 防双重编号 |
| `h4(text, auto_number)` | **四级标题**(公文/法律规范)| 同上 |
| `body(text)` | 正文段落 | 支持 Markdown 语法 |
| `bullet(text, level)` | 项目符号列表 | `level=0/1/2` |
| `numbered(text, level)` | **编号列表,换章节自动从1重置** | `level=0/1/2` |
| `abstract(text, keywords)` | **学术摘要**(仿宋左右缩进2字符)| `keywords` 可选 |
| `footnote_ref(text, note)` | **脚注引用**(学术引文) | `note` 脚注内容 |
| `module_heading(name)` | **【模块名称】**(药品说明书) | — |
| `warning(text)` | **⚠ 警告/提示**(红色加粗) | — |
| `signature_block(*lines)` | **落款/签名区**(居中) | 多参数多行 |
| `figure_caption(text, auto_number)` | **图题**(自动"图N",置图下方) | — |
| `table(headers, rows, caption, bold_rows)` | 规范表格 | `bold_rows` 加粗行 |
| `quote(text)` | 引用/法条(blockquote) | — |
| `code_block(code, lang)` | 代码块(缩进完整保留) | `lang="python"` |
| `toc_entry(text, page, level)` | 目录条目(带点线)| `level=1/2/3` |
| `highlight(text)` | 点缀色高亮 | — |
| `divider()` | 分隔线 | — |
| `page_break()` | 分页符 | — |
| `spacer(n)` | 空行 | `n=1` |
| `raw_markdown(md)` | 原始 Markdown | 高级用法 |
| `to_markdown()` | 返回 Markdown(调试) | — |
| `reset_numbering()` | 重置所有计数器 | — |
| `save(path)` | **生成 docx**(调用 Pandoc) | 完整路径 |
---
## 通用排版规范(原指南第八章)
### 字体规范
| 类别 | 中文 | 英文 |
|-----|------|------|
| 正式文档(公文/法律/学术) | 宋体/仿宋/黑体/楷体 | Times New Roman |
| 技术文档(SRS/手册/工程) | 宋体/黑体 | Calibri |
| 商业文档(计划书/营销) | 黑体/宋体 | Calibri |
| 特殊(标书目录) | 微软雅黑 | Calibri |
### 表格规范(原指南第八章第3节)
- 外框 1.5pt 粗线,内框 0.5pt 细线(模板中设置)
- 表头:加粗 + 主题色底色 + 白字
- 文字左对齐,数字右对齐(自动检测)
- 表题置上,图题置下(原指南明确规定)
### 配色法则(60-30-10)
| 角色 | 比例 | 颜色 |
|-----|------|------|
| 主色 | 60% | 深蓝#2F5496(商业/营销)/ 黑#000000(公文/学术) |
| 辅助色 | 30% | 浅灰#D9D9D9 |
| 点缀色 | 10% | 橙色#FFC000(商业)/ 红色#CC0000(公文/法律/警告) |
### 首页页码
- **党政公文**:首页不显示页码(`GOV_DOC` 模板已设置 `differentFirstPage=True`)
- 其他类型:页脚居中,字号见各类型规范
---
## 常见问题
| 问题 | 解决方案 |
|------|---------|
| `pandoc: command not found` | 安装 Pandoc |
| 模板文件不存在 | `python3 build_templates.py` |
| 中文字体异常(Linux) | `sudo apt install fonts-noto-cjk` |
| 编号未从1重置 | 确保两个列表间有 `h1/h2/body` 调用 |
| 代码缩进丢失 | 确认 Pandoc >= 2.0;使用 `code_block()` |
| 四级标题不显示 | `GOV_DOC`/`LEGAL_DOC` 类型支持 h4 |
---
---
## 扩展功能:读取已有 Word 文档并重新排版
> **说明**:此功能是独立新增模块,不影响上述所有原有功能。
> 使用前请确保 `docx_reader.py` 和 `reformat.py` 与 `converter.py` 在同一目录。
### 解决的问题
OpenClaw 无法直接读取 `.docx` 二进制文件。此模块在 Skill 内部增加**提取层**,
将二进制自动转换为结构化内容,再交给现有排版引擎处理:
```
用户的旧 .docx(二进制)
↓ docx_reader.py(提取层)
结构化内容块(标题/正文/列表/表格/代码块)
↓ reformat.py(重排版层)
DocxConverter → Pandoc + reference.docx 模板(原有排版引擎,不变)
↓
重新排版的新 .docx
```
### 新增文件
| 文件 | 作用 |
|------|------|
| `docx_reader.py` | 从任意 .docx 提取结构化内容(标题/正文/列表/表格/代码块)|
| `reformat.py` | 一键重排版入口:读取 → 自动推断类型 → 输出新文档 |
### 命令行用法
```bash
# 最简用法:自动推断类型,输出到同目录
python3 reformat.py 我的文档.docx
# 指定输出路径 + 强制文档类型
python3 reformat.py 旧文档.docx 新文档.docx --type GOV_DOC
# 先预览提取内容,确认正确后再生成(推荐首次使用)
python3 reformat.py 文档.docx --dry-run
# 生成文档的同时查看 Markdown 中间产物
python3 reformat.py 文档.docx --show-md
```
所有支持的 `--type` 值与上方文档类型表完全相同(19种)。
### OpenClaw Skill 内调用
```python
import sys
sys.path.insert(0, "/path/to/skill/")
from reformat import reformat
# 全自动:读取 → 推断类型 → 重排版 → 输出
result = reformat(
input_path = "用户的旧文档.docx",
output_path = "/mnt/user-data/outputs/重排版后文档.docx",
# doc_type = "TECH_MANUAL", # 可选:强制指定类型,覆盖自动推断
)
print(result["doc_type"]) # 实际使用的文档类型
print(result["stats"]) # {'headings':38, 'paragraphs':33, 'tables':6, ...}
```
### 自动提取能力
| 内容类型 | 提取方式 | 可靠性 |
|---------|---------|-------|
| 标题层级(h1~h4) | 优先用 Word 样式名,回退到字号+加粗启发式 | ⭐⭐⭐⭐⭐ |
| 正文段落 | 直接读取 | ⭐⭐⭐⭐⭐ |
| 项目符号列表 | 样式名 + XML numPr | ⭐⭐⭐⭐⭐ |
| 编号列表 | 样式名 + XML numPr | ⭐⭐⭐⭐ |
| 表格 | python-docx Table API | ⭐⭐⭐⭐⭐ |
| 代码块 | Courier New 字体检测 + 代码样式名 | ⭐⭐⭐⭐ |
| 文档类型自动推断 | 19种类型关键词评分 | ⭐⭐⭐⭐ |
| 警告/摘要/落款识别 | 文本正则模式匹配 | ⭐⭐⭐ |
### 局限性说明
| 局限 | 处理建议 |
|------|---------|
| 图片无法提取(二进制数据) | 排版后手动在 Word 中插入图片 |
| 文档全用 Normal 样式(无 Heading)| 先 `--dry-run` 检查,再 `--type` 强制指定类型 |
| 复杂嵌套/合并单元格表格 | 提取为扁平结构,复杂格式需手动调整 |
| 脚注/尾注内容 | 自动跳过,排版后可用 `footnote_ref()` 手动添加 |
FILE:build_templates.py
#!/usr/bin/env python3
"""
build_templates.py — 构建所有 reference.docx 模板(v4.1)
依据《Word文档类型与风格指南》完整实现所有文档类型的排版规范。
运行:python3 build_templates.py
输出:templates/ 目录下 9 个模板文件
"""
import os, platform
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
_OS = platform.system()
# ── 跨平台字体映射 ──────────────────────────────────────────
_FONT_MAP = {
"宋体": {"Windows": "宋体", "Darwin": "宋体-简", "Linux": "Noto Serif CJK SC"},
"黑体": {"Windows": "黑体", "Darwin": "黑体-简", "Linux": "Noto Sans CJK SC"},
"楷体": {"Windows": "楷体", "Darwin": "楷体-简", "Linux": "AR PL UKai CN"},
"仿宋": {"Windows": "仿宋", "Darwin": "仿宋-简", "Linux": "AR PL UMing HK"},
"微软雅黑": {"Windows": "微软雅黑", "Darwin": "PingFang SC", "Linux": "Noto Sans CJK SC"},
# 英文字体(正式文档)
"TNR": {"Windows": "Times New Roman", "Darwin": "Times New Roman", "Linux": "Liberation Serif"},
# 英文字体(技术文档)
"Calibri": {"Windows": "Calibri", "Darwin": "Helvetica Neue","Linux": "DejaVu Sans"},
}
def F(name):
return _FONT_MAP.get(name, {}).get(_OS, name)
# ── 中文字号 → 磅值 ─────────────────────────────────────────
CN_PT = {
"初号": 42, "小初": 36, "一号": 26, "小一": 24,
"二号": 22, "小二": 18, "三号": 16, "小三": 15,
"四号": 14, "小四": 12, "五号": 10.5, "小五": 9,
}
def P(name): return CN_PT[name]
ALIGN = {
"center": WD_ALIGN_PARAGRAPH.CENTER,
"left": WD_ALIGN_PARAGRAPH.LEFT,
"right": WD_ALIGN_PARAGRAPH.RIGHT,
"justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
}
# ── 模板完整配置(依据指南全量实现)────────────────────────
TEMPLATES = {
# ── 党政机关公文(GB/T 9704-2012)───────────────────────
"GOV_DOC": {
"page": {"w":21,"h":29.7,"mt":3.7,"mb":3.5,"ml":2.8,"mr":2.6},
"first_page_no_footer": True, # 首页不显示页码
"title": {"font":"宋体","sz":"二号","bold":False,"align":"center","sb":24,"sa":12,
"en_font":"TNR"}, # 小标宋用宋体模拟
"h1": {"font":"黑体","sz":"三号","bold":True, "align":"left","sb":14,"sa":6},
"h2": {"font":"楷体","sz":"三号","bold":True, "align":"left","sb":8, "sa":4},
"h3": {"font":"仿宋","sz":"三号","bold":True, "align":"left","sb":4, "sa":2},
"h4": {"font":"仿宋","sz":"三号","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"仿宋","sz":"三号","ls_mode":"exact","ls":28,
"first_indent":True,"en_font":"TNR"}, # 每页22行/每行28字由ls控制
"footer_sz":"四号",
"accent":"000000","sub_color":"333333","highlight":"CC0000",
"table_header_bg":"404040","table_border_outer":12,"table_border_inner":4,
},
# ── 专用公文:司法文书 ───────────────────────────────────
"GOV_JUDICIAL": {
"page": {"w":21,"h":29.7,"mt":2.54,"mb":2.54,"ml":3.17,"mr":3.17},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":18,"sa":9,
"en_font":"TNR"},
"h1": {"font":"黑体","sz":"三号","bold":True,"align":"left","sb":12,"sa":6},
"h2": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":8,"sa":4},
"h3": {"font":"仿宋","sz":"小四","bold":False,"align":"left","sb":4,"sa":2},
"h4": {"font":"仿宋","sz":"小四","bold":False,"align":"left","sb":2,"sa":1},
"body": {"font":"宋体","sz":"四号","ls_mode":"exact","ls":28,
"first_indent":True,"en_font":"TNR"},
"footer_sz":"五号",
"accent":"1F3864","sub_color":"333333","highlight":"CC0000",
"table_header_bg":"1F3864","table_border_outer":12,"table_border_inner":4,
},
# ── 商业文档:合同/协议 + 标书(共用模板)──────────────
"BUSINESS_CONTRACT": {
"page": {"w":21,"h":29.7,"mt":2.5,"mb":2.5,"ml":3.0,"mr":2.5},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":18,"sa":9,
"en_font":"TNR"},
"h1": {"font":"黑体","sz":"小四","bold":True,"align":"left","sb":12,"sa":6},
"h2": {"font":"楷体","sz":"小四","bold":True,"align":"left","sb":8,"sa":4},
"h3": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":4,"sa":2},
"h4": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":2,"sa":1},
"body": {"font":"宋体","sz":"小四","ls_mode":"multiple","ls":1.5,
"first_indent":True,"en_font":"TNR"},
"footer_sz":"小五",
"accent":"1F3864","sub_color":"D9D9D9","highlight":"FFC000",
"table_header_bg":"1F3864","table_border_outer":12,"table_border_inner":4,
},
# ── 商业文档:标书/投标文件(微软雅黑目录,特殊行距)───
"BUSINESS_TENDER": {
"page": {"w":21,"h":29.7,"mt":2.6,"mb":2.2,"ml":2.5,"mr":2.5},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":18,"sa":9,
"en_font":"Calibri"},
"h1": {"font":"微软雅黑","sz":"小四","bold":True,"align":"left","sb":12,"sa":6},
"h2": {"font":"宋体", "sz":"小四","bold":True,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体", "sz":"五号","bold":False,"align":"left","sb":4,"sa":2},
"h4": {"font":"宋体", "sz":"五号","bold":False,"align":"left","sb":2,"sa":1},
"body": {"font":"宋体", "sz":"小四","ls_mode":"multiple","ls":1.5,
"first_indent":True,"en_font":"Calibri"},
"footer_sz":"小五",
"accent":"2F5496","sub_color":"D9D9D9","highlight":"FFC000",
"table_header_bg":"2F5496","table_border_outer":12,"table_border_inner":4,
},
# ── 商业文档:商业计划书/创业计划书 ─────────────────────
"BUSINESS_PLAN": {
"page": {"w":21,"h":29.7,"mt":2.5,"mb":2.2,"ml":3.0,"mr":2.5},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"小初","bold":True,"align":"center","sb":36,"sa":12,
"en_font":"Calibri"},
"h1": {"font":"黑体","sz":"小四","bold":True,"align":"left","sb":14,"sa":6},
"h2": {"font":"宋体","sz":"小四","bold":True,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"小四","ls_mode":"multiple","ls":1.5,
"first_indent":True,"en_font":"Calibri"},
"footer_sz":"小五",
"accent":"2F5496","sub_color":"D9D9D9","highlight":"FFC000", # 60-30-10
"table_header_bg":"2F5496","table_border_outer":12,"table_border_inner":4,
},
# ── 学术文档:论文/研究报告 ─────────────────────────────
"ACADEMIC_PAPER": {
"page": {"w":21,"h":29.7,"mt":2.5,"mb":2.5,"ml":3.1,"mr":3.2},
"first_page_no_footer": False,
"title": {"font":"仿宋","sz":"三号","bold":False,"align":"center","sb":48,"sa":30,
"en_font":"TNR"}, # 前空三行≈3×16pt=48pt,后空两行≈2×16pt=32pt
"h1": {"font":"黑体","sz":"四号","bold":True, "align":"left","sb":16,"sa":8},
"h2": {"font":"楷体","sz":"小四","bold":False,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"五号","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"宋体","sz":"五号","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"五号","ls_mode":"multiple","ls":1.25,
"first_indent":True,"en_font":"TNR"},
"footer_sz":"五号",
"accent":"000000","sub_color":"444444","highlight":"CC0000",
"table_header_bg":"404040","table_border_outer":12,"table_border_inner":4,
},
# ── 学术文档:教案/教学大纲 ─────────────────────────────
"ACADEMIC_LESSON": {
"page": {"w":21,"h":29.7,"mt":2.54,"mb":2.54,"ml":3.17,"mr":3.17},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":18,"sa":9,
"en_font":"Calibri"},
"h1": {"font":"黑体","sz":"四号","bold":True,"align":"left","sb":14,"sa":6},
"h2": {"font":"宋体","sz":"小四","bold":True,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"小四","ls_mode":"exact","ls":24,
"first_indent":True,"en_font":"Calibri"}, # 固定24磅(20-28磅取中)
"footer_sz":"小五",
"accent":"1A5276","sub_color":"D9D9D9","highlight":"FFC000",
"table_header_bg":"1A5276","table_border_outer":12,"table_border_inner":4,
},
# ── 技术文档:SRS + 用户手册(共用,章节编号1.1.1)──────
"TECH_MANUAL": {
"page": {"w":21,"h":29.7,"mt":2.5,"mb":2.5,"ml":2.5,"mr":2.5},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"四号","bold":True,"align":"center","sb":24,"sa":12,
"en_font":"Calibri"},
"h1": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":12,"sa":6},
"h2": {"font":"楷体","sz":"四号","bold":False,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"五号","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"宋体","sz":"五号","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"五号","ls_mode":"multiple","ls":1.25,
"first_indent":False,"en_font":"Calibri"}, # 技术文档不缩进
"footer_sz":"五号",
"accent":"0070C0","sub_color":"D9D9D9","highlight":"FFC000",
"table_header_bg":"0070C0","table_border_outer":12,"table_border_inner":4,
},
# ── 医疗文档:病历/药品说明书 ───────────────────────────
"MEDICAL_DOC": {
"page": {"w":21,"h":29.7,"mt":2.54,"mb":2.54,"ml":3.17,"mr":3.17},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":18,"sa":9,
"en_font":"TNR"},
"h1": {"font":"黑体","sz":"三号","bold":True,"align":"left","sb":12,"sa":6},
"h2": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":8, "sa":4},
"h3": {"font":"仿宋","sz":"小四","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"仿宋","sz":"小四","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"仿宋","sz":"四号","ls_mode":"exact","ls":28,
"first_indent":True,"en_font":"TNR"},
"footer_sz":"小五",
"accent":"117A65","sub_color":"D9D9D9","highlight":"CC0000",
"table_header_bg":"117A65","table_border_outer":12,"table_border_inner":4,
},
# ── 营销/策划文档 ────────────────────────────────────────
"MARKETING_DOC": {
"page": {"w":21,"h":29.7,"mt":2.5,"mb":2.2,"ml":3.0,"mr":2.5},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"小初","bold":True,"align":"center","sb":36,"sa":12,
"en_font":"Calibri"},
"h1": {"font":"黑体","sz":"小四","bold":True,"align":"left","sb":14,"sa":6},
"h2": {"font":"宋体","sz":"小四","bold":True,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"小四","ls_mode":"multiple","ls":1.5,
"first_indent":True,"en_font":"Calibri"},
"footer_sz":"小五",
"accent":"2F5496","sub_color":"D9D9D9","highlight":"FFC000",
"table_header_bg":"2F5496","table_border_outer":12,"table_border_inner":4,
},
# ── 法律文书 ─────────────────────────────────────────────
"LEGAL_DOC": {
"page": {"w":21,"h":29.7,"mt":2.54,"mb":2.54,"ml":3.17,"mr":3.17},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":18,"sa":9,
"en_font":"TNR"},
"h1": {"font":"黑体","sz":"小四","bold":True,"align":"left","sb":12,"sa":6},
"h2": {"font":"楷体","sz":"小四","bold":True,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"仿宋","sz":"小四","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"小四","ls_mode":"multiple","ls":1.5,
"first_indent":True,"en_font":"TNR"},
"footer_sz":"小五",
"accent":"1F3864","sub_color":"D9D9D9","highlight":"CC0000",
"table_header_bg":"1F3864","table_border_outer":12,"table_border_inner":4,
},
# ── 金融报告 ─────────────────────────────────────────────
"FINANCE_REPORT": {
"page": {"w":21,"h":29.7,"mt":2.54,"mb":2.54,"ml":3.17,"mr":3.17},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":18,"sa":9,
"en_font":"TNR"},
"h1": {"font":"黑体","sz":"四号","bold":True,"align":"left","sb":12,"sa":6},
"h2": {"font":"楷体","sz":"小四","bold":True,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"四号","ls_mode":"exact","ls":28,
"first_indent":True,"en_font":"TNR"},
"footer_sz":"五号",
"accent":"2F5496","sub_color":"D9D9D9","highlight":"FFC000",
"table_header_bg":"2F5496","table_border_outer":12,"table_border_inner":4,
},
# ── 工程文档 ─────────────────────────────────────────────
"ENGINEERING_DOC": {
"page": {"w":21,"h":29.7,"mt":2.54,"mb":2.54,"ml":3.17,"mr":3.17},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":18,"sa":9,
"en_font":"Calibri"},
"h1": {"font":"黑体","sz":"四号","bold":True,"align":"center","sb":12,"sa":6},
"h2": {"font":"楷体","sz":"小四","bold":True,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"小四","ls_mode":"multiple","ls":1.25,
"first_indent":False,"en_font":"Calibri"},
"footer_sz":"小五",
"accent":"1A5276","sub_color":"D9D9D9","highlight":"FFC000",
"table_header_bg":"1A5276","table_border_outer":12,"table_border_inner":4,
},
# ── 通用商务文档(兜底)─────────────────────────────────
"GENERAL_DOC": {
"page": {"w":21,"h":29.7,"mt":2.5,"mb":2.5,"ml":3.0,"mr":2.5},
"first_page_no_footer": False,
"title": {"font":"黑体","sz":"三号","bold":True,"align":"center","sb":24,"sa":12,
"en_font":"Calibri"},
"h1": {"font":"黑体","sz":"四号","bold":True,"align":"left","sb":12,"sa":6},
"h2": {"font":"宋体","sz":"小四","bold":True,"align":"left","sb":8, "sa":4},
"h3": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":4, "sa":2},
"h4": {"font":"宋体","sz":"小四","bold":False,"align":"left","sb":2, "sa":1},
"body": {"font":"宋体","sz":"小四","ls_mode":"multiple","ls":1.5,
"first_indent":True,"en_font":"Calibri"},
"footer_sz":"小五",
"accent":"2F5496","sub_color":"D9D9D9","highlight":"FFC000",
"table_header_bg":"2F5496","table_border_outer":12,"table_border_inner":4,
},
}
# ══════════════════════════════════════════════════════════════
# 模板构建函数
# ══════════════════════════════════════════════════════════════
def _set_style(doc, name, font, sz_name, bold=False, color_hex=None,
sb=0, sa=0, ls=None, ls_mode=None, fi=None,
align=WD_ALIGN_PARAGRAPH.LEFT, outline=None,
en_font=None):
try: sty = doc.styles[name]
except: sty = doc.styles.add_style(name, 1)
sz_pt = P(sz_name)
sty.font.name = font
# 同时设置东亚/西文字体
try:
rPr = sty.element.get_or_add_rPr()
rFonts = rPr.find(qn("w:rFonts"))
if rFonts is None:
rFonts = OxmlElement("w:rFonts"); rPr.insert(0, rFonts)
rFonts.set(qn("w:eastAsia"), font)
if en_font:
rFonts.set(qn("w:ascii"), F(en_font))
rFonts.set(qn("w:hAnsi"), F(en_font))
except: pass
sty.font.size = Pt(sz_pt)
sty.font.bold = bold
if color_hex:
sty.font.color.rgb = RGBColor(
int(color_hex[0:2],16), int(color_hex[2:4],16), int(color_hex[4:6],16))
pf = sty.paragraph_format
pf.space_before = Pt(sb)
pf.space_after = Pt(sa)
pf.alignment = align
if ls_mode == "exact":
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.line_spacing = Pt(ls)
elif ls_mode == "multiple" and ls:
pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
pf.line_spacing = ls
if fi is not None:
pf.first_line_indent = Pt(fi)
if outline is not None:
pPr = sty.element.get_or_add_pPr()
for old in pPr.findall(qn("w:outlineLvl")):
pPr.remove(old)
ol = OxmlElement("w:outlineLvl")
ol.set(qn("w:val"), str(outline))
pPr.append(ol)
return sty
def _setup_numbering(doc):
"""建立编号体系:abstractNum 0=bullet,1=decimal"""
try:
nb = doc.part.numbering_part._element
except:
return
for c in list(nb):
nb.remove(c)
def el(tag, **kw):
e = OxmlElement(f"w:{tag}")
for k, v in kw.items():
e.set(qn(f"w:{k}"), str(v))
return e
for abs_id, fmt, txt in [("0","bullet","•"), ("1","decimal","%1.")]:
an = el("abstractNum", abstractNumId=abs_id)
an.append(el("multiLevelType", val="hybridMultilevel"))
for i in range(3):
lvl = el("lvl", ilvl=str(i))
lvl.append(el("start", val="1"))
lvl.append(el("numFmt", val=fmt))
lvl.append(el("lvlText", val=txt))
ind = el("ind", left=str(360+i*360), hanging="240")
pp = OxmlElement("w:pPr"); pp.append(ind); lvl.append(pp)
an.append(lvl)
nb.append(an)
for num_id, abs_id in [("1","0"), ("2","1")]:
num = el("num", numId=num_id)
num.append(el("abstractNumId", val=abs_id))
nb.append(num)
def _add_footer(doc, cfg):
"""添加页脚页码"""
footer_sz = P(cfg.get("footer_sz","小五"))
body_font = cfg["body"]["font"]
sec = doc.sections[0]
sec.different_first_page_header_footer = cfg.get("first_page_no_footer", False)
fp = sec.footer.paragraphs[0]
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
fp.clear()
run = fp.add_run()
run.font.name = F(body_font)
run.font.size = Pt(footer_sz)
run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
try:
rPr = run._r.get_or_add_rPr()
rFonts = rPr.find(qn("w:rFonts"))
if rFonts is None:
rFonts = OxmlElement("w:rFonts"); rPr.insert(0, rFonts)
rFonts.set(qn("w:eastAsia"), F(body_font))
except: pass
# PAGE 域
for tag, txt in [("begin",None),("instrText"," PAGE "),("separate",None),("end",None)]:
if tag == "instrText":
e = OxmlElement("w:instrText")
e.set(qn("xml:space"), "preserve")
e.text = txt
run._r.append(e)
else:
e = OxmlElement("w:fldChar")
e.set(qn("w:fldCharType"), tag)
run._r.append(e)
def build_template(key, cfg, out_dir):
doc = Document()
pg = cfg["page"]
sec = doc.sections[0]
sec.page_width = Cm(pg["w"]); sec.page_height = Cm(pg["h"])
sec.top_margin = Cm(pg["mt"]); sec.bottom_margin = Cm(pg["mb"])
sec.left_margin = Cm(pg["ml"]); sec.right_margin = Cm(pg["mr"])
_setup_numbering(doc)
acc = cfg["accent"]
bd = cfg["body"]
fi_pt = P(bd["sz"]) * 2 if bd.get("first_indent") else 0
# Normal(正文基础)
_set_style(doc, "Normal", F(bd["font"]), bd["sz"], False,
ls=bd["ls"], ls_mode=bd["ls_mode"], fi=fi_pt,
align=WD_ALIGN_PARAGRAPH.JUSTIFY,
en_font=bd.get("en_font","Calibri"))
# Title
t = cfg["title"]
_set_style(doc, "Title", F(t["font"]), t["sz"], t["bold"],
color_hex=acc, sb=t["sb"], sa=t["sa"],
align=ALIGN[t["align"]], en_font=t.get("en_font"))
# Heading 1–4
for lvl_key, sname, outline in [
("h1","Heading 1",0), ("h2","Heading 2",1),
("h3","Heading 3",2), ("h4","Heading 4",3)
]:
h = cfg[lvl_key]
_set_style(doc, sname, F(h["font"]), h["sz"], h["bold"],
color_hex=acc if lvl_key=="h1" else None,
sb=h["sb"], sa=h["sa"],
align=ALIGN[h["align"]], outline=outline, fi=0)
# Verbatim / Code(代码块样式,Pandoc使用)
for sname in ["Verbatim Char", "Source Code", "Code", "Verbatim"]:
try:
_set_style(doc, sname, sname, "Courier New", "小五", False,
ls=1.2, ls_mode="multiple")
except: pass
_add_footer(doc, cfg)
out = os.path.join(out_dir, f"template_{key}.docx")
doc.save(out)
print(f" ✅ {key:22s} → {os.path.basename(out)}")
return out
if __name__ == "__main__":
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
os.makedirs(out_dir, exist_ok=True)
print(f"构建模板(平台: {_OS}):")
for key, cfg in TEMPLATES.items():
build_template(key, cfg, out_dir)
print(f"\n✅ 完成,共 {len(TEMPLATES)} 个模板")
FILE:converter.py
"""
converter.py — OpenClaw docx-output Skill v4.1 核心转换器
架构:Markdown(语义层)→ Pandoc + reference.docx(渲染层)→ .docx
v4.1 相对 v4.0 补全的缺口(依据《Word文档类型与风格指南》):
+ abstract(text, keywords) 学术摘要(仿宋、左右缩进2字符)
+ module_heading(name) 药品说明书【模块名称】
+ warning(text) 警告提示(红色加粗 ⚠)
+ signature_block(*lines) 落款/签名区(仿宋四号居中)
+ figure_caption(text) 图题(置图下方,五号仿宋居中)
+ footnote_ref(text, note) 脚注引用(学术引文,小五号)
+ toc_entry(text, page, level) 目录条目(带点线前导符)
+ h4(text) 四级标题 (1)(2)(3)(公文规范第四级)
+ highlight(text) 点缀色高亮文字
+ 英文字体规范 正式文档用TNR,技术文档用Calibri
+ 图表自动编号 fig_counter / tbl_counter(章节独立)
+ 首页不显示页码 模板 first_page_no_footer
"""
import os
import re
import subprocess
from pathlib import Path
_SKILL_DIR = Path(__file__).parent
_TEMPLATE_DIR = _SKILL_DIR / "templates"
# ══════════════════════════════════════════════════════════════
# 文档类型 → 模板映射(完整19种)
# ══════════════════════════════════════════════════════════════
DOC_TYPE_MAP = {
"GOV_DOC": "GOV_DOC",
"GOV_JUDICIAL": "GOV_JUDICIAL",
"GOV_DIPLOMATIC": "GOV_JUDICIAL",
"BUSINESS_CONTRACT": "BUSINESS_CONTRACT",
"BUSINESS_TENDER": "BUSINESS_TENDER",
"BUSINESS_PLAN": "BUSINESS_PLAN",
"ACADEMIC_PAPER": "ACADEMIC_PAPER",
"ACADEMIC_LESSON": "ACADEMIC_LESSON",
"TECH_SRS": "TECH_MANUAL",
"TECH_MANUAL": "TECH_MANUAL",
"MEDICAL_RECORD": "MEDICAL_DOC",
"MEDICAL_DRUG": "MEDICAL_DOC",
"MARKETING_PLAN": "MARKETING_DOC",
"MARKETING_ANALYSIS": "MARKETING_DOC",
"LEGAL_OPINION": "LEGAL_DOC",
"LEGAL_LITIGATION": "LEGAL_DOC",
"FINANCE_REPORT": "FINANCE_REPORT",
"ENGINEERING_DOC": "ENGINEERING_DOC",
"GENERAL_DOC": "GENERAL_DOC",
}
# ══════════════════════════════════════════════════════════════
# 各类型序号风格
# ══════════════════════════════════════════════════════════════
DOC_PREFIX = {
"GOV_DOC": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"GOV_JUDICIAL": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"BUSINESS_CONTRACT": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"BUSINESS_TENDER": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"BUSINESS_PLAN": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"ACADEMIC_PAPER": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"ACADEMIC_LESSON": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"TECH_MANUAL": {"h1":"num", "h2":"num2","h3":"num3","h4":"num4"},
"MARKETING_DOC": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"LEGAL_DOC": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"FINANCE_REPORT": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"ENGINEERING_DOC": {"h1":"num", "h2":"num2","h3":"dec", "h4":"paren"},
"MEDICAL_DOC": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
"GENERAL_DOC": {"h1":"cn", "h2":"cn2", "h3":"dec", "h4":"paren"},
}
# ══════════════════════════════════════════════════════════════
# 智能序号检测(防止双重编号)
# ══════════════════════════════════════════════════════════════
_NUMBERING_RE = [
re.compile(r'^[一二三四五六七八九十百]+[、..]'),
re.compile(r'^[((][一二三四五六七八九十]+[))]'),
re.compile(r'^\d+[..]\d+'),
re.compile(r'^\d+[..]\s'),
re.compile(r'^\(\d+\)'),
re.compile(r'^(\d+)'),
re.compile(r'^第[一二三四五六七八九十百\d]+[章节条款]'),
re.compile(r'^[【\[][^\]】]+[】\]]'),
re.compile(r'^[A-Za-z][..]\d'),
re.compile(r'^\d+[..][^\d\s]'),
]
def _has_number(text):
s = text.strip()
return any(p.match(s) for p in _NUMBERING_RE)
# 序号字符串
_CN1 = ["一","二","三","四","五","六","七","八","九","十",
"十一","十二","十三","十四","十五"]
_CN2 = ["一","二","三","四","五","六","七","八","九","十"]
class _Counter:
def __init__(self):
self.reset()
def reset(self):
self.h1=self.h2=self.h3=self.h4=0
self.fig=self.tbl=0 # 图表计数
self._fn_idx=0 # 脚注计数
def reset_sub(self):
self.h2=self.h3=self.h4=0
def reset_h3h4(self):
self.h3=self.h4=0
def reset_h4(self):
self.h4=0
def pfx_h1(self, style, text):
if _has_number(text): self.reset_sub(); return text
self.h1+=1; self.h2=self.h3=self.h4=0; n=self.h1
if style in ("cn","gov"): return f"{_CN1[n-1]}、{text}"
if style=="num": return f"{n}. {text}"
return text
def pfx_h2(self, style, text):
if _has_number(text): self.reset_h3h4(); return text
self.h2+=1; self.h3=self.h4=0; n=self.h2
if style in ("cn2","gov2"): return f"({_CN2[n-1]})\u3000{text}"
if style=="num2": return f"{self.h1}.{n} {text}"
return text
def pfx_h3(self, style, text):
if _has_number(text): self.reset_h4(); return text
self.h3+=1; self.h4=0; n=self.h3
if style=="dec": return f"{n}. {text}"
if style=="num3": return f"{self.h1}.{self.h2}.{n} {text}"
return text
def pfx_h4(self, style, text):
if _has_number(text): return text
self.h4+=1; n=self.h4
if style=="paren": return f"({n})\u3000{text}"
if style=="num4": return f"{self.h1}.{self.h2}.{self.h3}.{n} {text}"
return text
def next_fig(self):
self.fig+=1; return self.fig
def next_tbl(self):
self.tbl+=1; return self.tbl
def next_fn(self):
self._fn_idx+=1; return self._fn_idx
# ══════════════════════════════════════════════════════════════
# DocxConverter 主类
# ══════════════════════════════════════════════════════════════
class DocxConverter:
"""
将结构化内容转换为 Markdown,由 Pandoc + reference.docx 渲染为 docx。
快速开始:
from converter import DocxConverter
c = DocxConverter("TECH_MANUAL")
c.title("AI学习与开发实用手册")
c.h1("AI学习基础篇")
c.body("正文...")
c.numbered("数学基础") # → 1
c.numbered("编程技能") # → 2
c.h1("机器学习")
c.numbered("监督学习") # → 1(自动重置!)
c.save("output.docx")
"""
def __init__(self, doc_type: str = "GENERAL_DOC"):
tpl_key = DOC_TYPE_MAP.get(doc_type, "GENERAL_DOC")
self.template = _TEMPLATE_DIR / f"template_{tpl_key}.docx"
if not self.template.exists():
self.template = _TEMPLATE_DIR / "template_GENERAL_DOC.docx"
pfx = DOC_PREFIX.get(tpl_key, DOC_PREFIX["GENERAL_DOC"])
self._p1 = pfx["h1"]; self._p2 = pfx["h2"]
self._p3 = pfx["h3"]; self._p4 = pfx["h4"]
self._ctr = _Counter()
self._blocks = []
self._in_list = None # None | "bullet" | "numbered"
self._footnotes = [] # [(anchor, text), ...]
# ── 私有:列表边界 ───────────────────────────────────────
def _end_list(self):
if self._in_list:
self._blocks.append("")
self._in_list = None
def _section_break(self):
"""标题/段落调用时,终止列表"""
self._end_list()
# ══════════════════════════════════════════════════════════
# 公开 API
# ══════════════════════════════════════════════════════════
def title(self, text: str) -> "DocxConverter":
"""文档主标题"""
self._blocks.append(f"% {text}")
return self
def subtitle(self, text: str) -> "DocxConverter":
"""副标题(显示为斜体段落,位于标题下方)"""
self._blocks.append(f"\n*{text}*\n")
return self
def h1(self, text: str, auto_number: bool = True) -> "DocxConverter":
self._section_break()
d = self._ctr.pfx_h1(self._p1, text) if auto_number else text
self._blocks.append(f"\n# {d}\n")
return self
def h2(self, text: str, auto_number: bool = True) -> "DocxConverter":
self._section_break()
d = self._ctr.pfx_h2(self._p2, text) if auto_number else text
self._blocks.append(f"\n## {d}\n")
return self
def h3(self, text: str, auto_number: bool = True) -> "DocxConverter":
self._section_break()
d = self._ctr.pfx_h3(self._p3, text) if auto_number else text
self._blocks.append(f"\n### {d}\n")
return self
def h4(self, text: str, auto_number: bool = True) -> "DocxConverter":
"""四级标题(公文规范第四层 (1)(2)(3))"""
self._section_break()
d = self._ctr.pfx_h4(self._p4, text) if auto_number else text
self._blocks.append(f"\n#### {d}\n")
return self
def body(self, text: str) -> "DocxConverter":
"""正文段落(支持 Markdown 行内语法:**加粗** *斜体* `代码`)"""
self._end_list()
self._blocks.append(f"\n{text}\n")
return self
def bullet(self, text: str, level: int = 0) -> "DocxConverter":
"""项目符号列表(•)"""
if self._in_list == "numbered":
self._end_list()
self._in_list = "bullet"
self._blocks.append(" " * level + f"- {text}")
return self
def numbered(self, text: str, level: int = 0) -> "DocxConverter":
"""
编号列表。每次 h1/h2/h3/body 调用后,下一个 numbered 自动从1重置。
Pandoc 对每个独立列表块天然从1计数,无需任何额外操作。
"""
if self._in_list == "bullet":
self._end_list()
self._in_list = "numbered"
self._blocks.append(" " * level + f"1. {text}")
return self
def abstract(self, text: str, keywords: str = None) -> "DocxConverter":
"""
学术摘要(ACADEMIC_PAPER 专用)。
输出小四号仿宋,左右缩进2字符,1.25倍行距。
Pandoc 中用 blockquote 近似实现缩进效果。
keywords 示例:"数字孪生;智慧城市;物联网"
"""
self._end_list()
self._blocks.append(f"\n> **摘要:**{text}\n")
if keywords:
self._blocks.append(f"\n> **关键词:**{keywords}\n")
return self
def module_heading(self, name: str) -> "DocxConverter":
"""
药品说明书专用:【模块名称】格式标题。
示例:c.module_heading("药品名称") → **【药品名称】**
"""
self._section_break()
self._blocks.append(f"\n**【{name}】**\n")
return self
def warning(self, text: str) -> "DocxConverter":
"""
警告/提示段落(用户手册、药品说明书等)。
输出红色加粗,用 Markdown 粗体 + emoji 近似实现。
"""
self._end_list()
self._blocks.append(f"\n**⚠ {text}**\n")
return self
def signature_block(self, *lines: str) -> "DocxConverter":
"""
落款/签名区(合同、法律文书、公文尾部)。
仿宋四号居中,自动上方留空白。
用 Pandoc 居中段落实现。
示例:c.signature_block("甲方:___________", "日期:___年___月___日")
"""
self._end_list()
self._blocks.append("")
for line in lines:
self._blocks.append(f"\n::: {{.center}}\n{line}\n:::\n")
return self
def figure_caption(self, text: str, auto_number: bool = True) -> "DocxConverter":
"""
图题(置于图片下方,五号仿宋居中)。
auto_number=True 时自动生成"图N "前缀,按文档全局计数。
示例:c.figure_caption("系统架构示意图") → *图1 系统架构示意图*
"""
self._end_list()
if auto_number:
n = self._ctr.next_fig()
display = f"图{n}\u3000{text}"
else:
display = text
self._blocks.append(f"\n*{display}*\n")
return self
def table_caption(self, text: str, auto_number: bool = True,
above: bool = True) -> "DocxConverter":
"""
表题(默认置于表格上方,五号仿宋居中)。
auto_number=True 时自动生成"表N "前缀。
若 above=False 则置于表格下方(图表规范:图题下置,表题上置)。
一般在 table() 之前调用(above=True)或之后调用(above=False)。
"""
self._end_list()
if auto_number:
n = self._ctr.next_tbl()
display = f"表{n}\u3000{text}"
else:
display = text
self._blocks.append(f"\n*{display}*\n")
return self
def footnote_ref(self, text: str, note: str) -> "DocxConverter":
"""
脚注引用(学术论文引文)。
在行内插入上标数字,脚注内容收集到文档末尾(Pandoc 原生支持)。
示例:c.footnote_ref("该理论由Turing提出", "Alan Turing, 1950, Computing Machinery and Intelligence.")
"""
self._end_list()
# Pandoc inline footnote: text^[note content]
self._blocks.append(f"\n{text}^[{note}]\n")
return self
def toc_entry(self, text: str, page: str,
level: int = 1) -> "DocxConverter":
"""
目录条目(带点线前导符,右对齐页码)。
level=1 加粗,level=2 普通,level=3 缩进。
Markdown 中用制表符+空格近似,实际点线由 Word 模板处理。
"""
self._end_list()
indent = "\u3000" * (level - 1)
bold_s = "**" if level == 1 else ""
bold_e = "**" if level == 1 else ""
self._blocks.append(f"{indent}{bold_s}{text}{bold_e}{'·'*4}{page}")
return self
def highlight(self, text: str) -> "DocxConverter":
"""
点缀色高亮文字(60-30-10法则中的10%点缀色)。
在 Markdown 中用行内 HTML span 实现(Pandoc 支持)。
"""
self._end_list()
self._blocks.append(f"\n[{text}]{{.highlight}}\n")
return self
def code_block(self, code: str, lang: str = "") -> "DocxConverter":
"""
代码块。Pandoc fenced code block 完整保留缩进和空白。
lang: 语言标注,如 "python"、"bash"、"sql"
"""
self._end_list()
self._blocks.append(f"\n```{lang}\n{code}\n```\n")
return self
def table(self, headers: list, rows: list,
caption: str = None, bold_rows: list = None) -> "DocxConverter":
"""
规范化表格(Pandoc pipe table)。
caption: 表题(自动加"表N"编号,置于表格上方)
bold_rows: 需要加粗的行序号列表(如 [0] 表示第一行数据加粗,
用于金融报告中关键数据加粗)
指南规范:外框1.5pt/内框0.5pt,表头浅蓝/浅灰底色,数字右对齐
"""
self._end_list()
if caption:
self.table_caption(caption)
n = len(headers)
# 表头行
hdr = "| " + " | ".join(f"**{h}**" for h in headers) + " |"
# 对齐行:检测是否为数字列(右对齐)
aligns = []
for col_i in range(n):
col_vals = [str(r[col_i]) if col_i < len(r) else "" for r in rows]
num_count = sum(1 for v in col_vals
if re.match(r'^[\d\+\-¥$¥€£%,,.\s]+$', v.strip()))
aligns.append("--:" if num_count > len(col_vals)/2 and col_i > 0 else ":--")
sep = "| " + " | ".join(aligns) + " |"
lines = [hdr, sep]
bold_set = set(bold_rows or [])
for ri, row in enumerate(rows):
cells = []
for ci, cell in enumerate(row):
val = str(cell)
if ri in bold_set:
val = f"**{val}**"
cells.append(val)
lines.append("| " + " | ".join(cells) + " |")
self._blocks.append("\n" + "\n".join(lines) + "\n")
return self
def quote(self, text: str) -> "DocxConverter":
"""引用/法条(Markdown blockquote,楷体悬挂缩进)"""
self._end_list()
self._blocks.append(f"\n> {text}\n")
return self
def divider(self) -> "DocxConverter":
"""水平分隔线"""
self._end_list()
self._blocks.append("\n---\n")
return self
def page_break(self) -> "DocxConverter":
"""分页符"""
self._end_list()
self._blocks.append('\n```{=openxml}\n<w:p><w:r><w:br w:type="page"/></w:r></w:p>\n```\n')
return self
def spacer(self, n: int = 1) -> "DocxConverter":
"""空行"""
for _ in range(n):
self._blocks.append("")
return self
def raw_markdown(self, md: str) -> "DocxConverter":
"""直接插入原始 Markdown(高级用法)"""
self._end_list()
self._blocks.append(md)
return self
def reset_numbering(self) -> "DocxConverter":
"""重置所有计数器(多文档或多章节独立编号时使用)"""
self._ctr.reset()
return self
# ── 生成 & 输出 ──────────────────────────────────────────
def to_markdown(self) -> str:
"""返回最终 Markdown 内容(调试用)"""
self._end_list()
return "\n".join(self._blocks)
def save(self, output_path: str) -> str:
"""
生成 Markdown → Pandoc → .docx
由 Pandoc 保证:编号正确、代码缩进保留、样式稳定。
"""
self._end_list()
md = "\n".join(self._blocks)
tmp_md = str(output_path).replace(".docx", "_tmp.md")
with open(tmp_md, "w", encoding="utf-8") as f:
f.write(md)
cmd = [
"pandoc", tmp_md,
"--from", "markdown+pipe_tables+fenced_code_blocks+inline_notes+raw_attribute",
"--to", "docx",
f"--reference-doc={self.template}",
"--output", output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Pandoc 转换失败:\n{result.stderr}")
try:
os.unlink(tmp_md)
except:
pass
size = os.path.getsize(output_path)
tpl_name = self.template.stem
print(f"✅ 已生成: {output_path} ({size:,} bytes) [模板: {tpl_name}]")
return output_path
FILE:docx_reader.py
"""
docx_reader.py — 从任意 .docx 文件提取结构化内容
输出:结构化的内容块列表,供 reformat.py 使用
支持提取:标题层级、正文段落、列表(有序/无序)、表格、代码块
跨平台,仅依赖 python-docx
"""
import re
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Pt
# ── 判断辅助 ──────────────────────────────────────────────────
def _pt(emu):
"""EMU → 磅"""
return round(emu / 12700, 1) if emu else None
def _is_heading_by_style(style_name: str):
"""通过样式名判断标题级别"""
name = style_name.lower().strip()
for kw, lvl in [("heading 1",1),("heading 2",2),("heading 3",3),("heading 4",4),
("标题 1",1),("标题 2",2),("标题 3",3),("标题 4",4),
("title",0)]:
if kw in name:
return lvl
return None
def _infer_heading_level(para, size_pt, bold, all_sizes):
"""
当样式为 Normal 时,通过字号+加粗启发式推断标题级别。
all_sizes: 文档中出现的所有字号(降序排列)
"""
if not bold or size_pt is None:
return None
# 按字号从大到小映射为标题级别
unique = sorted(set(all_sizes), reverse=True)
try:
rank = unique.index(size_pt) # 0=最大字号
if rank <= 3:
return rank + 1 # h1/h2/h3/h4
except ValueError:
pass
return None
def _is_code_block(para):
"""判断是否为代码块段落"""
style = para.style.name.lower()
if any(k in style for k in ("code","verbatim","source","mono","courier")):
return True
runs = para.runs
if runs and runs[0].font.name and "courier" in runs[0].font.name.lower():
return True
# 检测左缩进(代码块通常有固定缩进)
pf = para.paragraph_format
if pf.left_indent and pf.left_indent > Pt(20):
runs = para.runs
if runs and runs[0].font.name and (
"courier" in runs[0].font.name.lower() or
"consol" in runs[0].font.name.lower()
):
return True
return False
def _get_list_type(para):
"""判断列表类型:'bullet' / 'numbered' / None"""
style = para.style.name.lower()
if "list bullet" in style or "bullet" in style:
return "bullet"
if "list number" in style or "list cont" in style:
return "numbered"
# 从 XML numPr 判断
pPr = para._p.find(qn("w:pPr"))
if pPr is not None:
numPr = pPr.find(qn("w:numPr"))
if numPr is not None:
numFmt = None
# 尝试从 numbering 中查找
try:
numId_el = numPr.find(qn("w:numId"))
if numId_el is not None:
num_id = numId_el.get(qn("w:val"))
if num_id and num_id != "0":
# 有效列表
ilvl_el = numPr.find(qn("w:ilvl"))
ilvl = int(ilvl_el.get(qn("w:val"),0)) if ilvl_el is not None else 0
return "numbered" # 默认编号,后续可细化
except:
pass
return "numbered"
return None
def _table_to_dict(tbl):
"""表格 → 结构化字典"""
rows = []
for row in tbl.rows:
cells = []
for cell in row.cells:
text = " ".join(p.text.strip() for p in cell.paragraphs if p.text.strip())
cells.append(text)
if any(cells):
rows.append(cells)
if not rows:
return None
return {"type": "table", "headers": rows[0], "rows": rows[1:]}
# ══════════════════════════════════════════════════════════════
# 主提取函数
# ══════════════════════════════════════════════════════════════
def extract(docx_path: str) -> dict:
"""
从 .docx 文件提取结构化内容。
返回:
{
"title": "文档标题(若能识别)",
"blocks": [
{"type": "heading", "level": 1, "text": "章节一"},
{"type": "body", "text": "正文内容..."},
{"type": "bullet", "text": "列表项", "level": 0},
{"type": "numbered","text": "编号项", "level": 0},
{"type": "code", "text": "代码内容..."},
{"type": "table", "headers": [...], "rows": [[...],[...]]},
{"type": "divider"},
],
"doc_hint": "推断的文档类型关键词",
}
"""
doc = Document(docx_path)
# 第一步:收集所有段落的字号,用于启发式标题判断
bold_sizes = []
for para in doc.paragraphs:
if not para.text.strip(): continue
runs = para.runs
if runs and runs[0].font.bold and runs[0].font.size:
bold_sizes.append(_pt(runs[0].font.size))
bold_sizes = [s for s in bold_sizes if s]
# 第二步:构建段落+表格的顺序列表(按文档顺序)
# python-docx 不直接支持混合顺序,通过 XML body 遍历
body = doc.element.body
para_iter = iter(doc.paragraphs)
table_iter = iter(doc.tables)
para_map = {} # xml element → Para
table_map = {} # xml element → Table
for p in doc.paragraphs:
para_map[id(p._p)] = p
for t in doc.tables:
table_map[id(t._tbl)] = t
ordered_items = [] # [(type, obj)]
for child in body:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == "p":
p = para_map.get(id(child))
if p: ordered_items.append(("para", p))
elif tag == "tbl":
t = table_map.get(id(child))
if t: ordered_items.append(("table", t))
# 第三步:提取内容块
blocks = []
title_text = None
code_buffer = [] # 合并连续代码行
def flush_code():
if code_buffer:
blocks.append({"type": "code", "text": "\n".join(code_buffer)})
code_buffer.clear()
for item_type, obj in ordered_items:
if item_type == "table":
flush_code()
td = _table_to_dict(obj)
if td:
blocks.append(td)
continue
# 段落处理
para = obj
text = para.text.strip()
if not text:
flush_code()
continue
style_name = para.style.name
runs = para.runs
size_emu = runs[0].font.size if runs and runs[0].font.size else None
size_pt = _pt(size_emu)
bold = runs[0].font.bold if runs else False
# 代码块检测
if _is_code_block(para):
# 保留原始缩进
code_buffer.append(para.text)
continue
else:
flush_code()
# 标题级别判断
lvl = _is_heading_by_style(style_name)
if lvl is None and bold and bold_sizes:
lvl = _infer_heading_level(para, size_pt, bold, bold_sizes)
if lvl == 0:
# Title 样式 → 文档主标题
title_text = title_text or text
blocks.append({"type": "heading", "level": 0, "text": text})
continue
if lvl:
blocks.append({"type": "heading", "level": lvl, "text": text})
continue
# 列表
list_type = _get_list_type(para)
if list_type:
pf = para.paragraph_format
indent_level = 0
if pf.left_indent:
indent_level = min(2, int(_pt(pf.left_indent) / 18))
blocks.append({"type": list_type, "text": text, "level": indent_level})
continue
# 分隔线检测(短横线段落)
if re.match(r'^[-─—=\*]{3,}$', text):
blocks.append({"type": "divider"})
continue
# 普通正文
blocks.append({"type": "body", "text": text})
flush_code()
# 第四步:推断文档类型线索
all_text = " ".join(b.get("text","") for b in blocks if "text" in b)
doc_hint = _infer_doc_type(all_text, title_text or "")
return {
"title": title_text or "",
"blocks": blocks,
"doc_hint": doc_hint,
"stats": {
"paragraphs": len([b for b in blocks if b["type"]=="body"]),
"headings": len([b for b in blocks if b["type"]=="heading"]),
"tables": len([b for b in blocks if b["type"]=="table"]),
"code_blocks":len([b for b in blocks if b["type"]=="code"]),
"list_items": len([b for b in blocks if b["type"] in ("bullet","numbered")]),
}
}
def _infer_doc_type(text: str, title: str) -> str:
"""从内容关键词推断文档类型"""
combined = (title + " " + text).lower()
rules = [
("GOV_DOC", ["通知", "决定", "报告", "命令", "党政", "机关", "公文", "发文"]),
("GOV_JUDICIAL", ["判决", "裁定", "起诉书", "司法", "案号", "法院"]),
("BUSINESS_CONTRACT",["合同", "协议", "甲方", "乙方", "签订", "违约", "租赁"]),
("BUSINESS_TENDER",["标书", "投标", "招标", "投标人", "评分", "报价"]),
("BUSINESS_PLAN", ["商业计划", "创业", "融资", "投资人", "bp", "商业模式", "市场规模"]),
("ACADEMIC_PAPER", ["摘要", "关键词", "abstract", "参考文献", "研究方法", "论文"]),
("ACADEMIC_LESSON",["教案", "教学目标", "教学大纲", "学时", "课程", "授课"]),
("TECH_SRS", ["需求规格", "功能需求", "srs", "用例", "非功能需求", "系统架构"]),
("TECH_MANUAL", ["用户手册", "操作指南", "安装", "配置", "步骤", "警告", "注意"]),
("MEDICAL_DRUG", ["药品", "适应症", "用法用量", "不良反应", "禁忌", "说明书"]),
("MEDICAL_RECORD", ["病历", "主诉", "诊断", "医嘱", "入院", "出院", "患者"]),
("MARKETING_PLAN", ["策划", "营销", "推广", "活动方案", "kpi", "品牌"]),
("MARKETING_ANALYSIS",["市场分析", "竞品", "swot", "行业报告", "市场份额"]),
("LEGAL_OPINION", ["法律意见", "律师", "当事人", "意见书"]),
("LEGAL_LITIGATION",["起诉状", "答辩状", "仲裁", "诉讼请求", "事实与理由"]),
("FINANCE_REPORT", ["财务报告", "资产负债", "利润", "现金流", "年报", "季报"]),
("ENGINEERING_DOC",["工程方案", "设计方案", "施工", "cad", "图纸", "工程量"]),
]
scores = {}
for doc_type, keywords in rules:
score = sum(1 for kw in keywords if kw in combined)
if score > 0:
scores[doc_type] = score
if scores:
return max(scores, key=scores.get)
return "GENERAL_DOC"
def to_markdown(extracted: dict) -> str:
"""将提取结果转为 Markdown(用于调试/预览)"""
lines = []
for b in extracted["blocks"]:
t = b["type"]
text = b.get("text", "")
if t == "heading":
lvl = b["level"]
prefix = "#" * max(1, lvl)
lines.append(f"\n{prefix} {text}\n")
elif t == "body":
lines.append(f"\n{text}\n")
elif t == "bullet":
ind = " " * b.get("level", 0)
lines.append(f"{ind}- {text}")
elif t == "numbered":
ind = " " * b.get("level", 0)
lines.append(f"{ind}1. {text}")
elif t == "code":
lines.append(f"\n```\n{text}\n```\n")
elif t == "table":
hdrs = b["headers"]
lines.append("\n| " + " | ".join(hdrs) + " |")
lines.append("| " + " | ".join(["---"]*len(hdrs)) + " |")
for row in b["rows"]:
lines.append("| " + " | ".join(row) + " |")
lines.append("")
elif t == "divider":
lines.append("\n---\n")
return "\n".join(lines)
def summary(extracted: dict) -> str:
"""打印提取摘要"""
s = extracted["stats"]
return (f"标题:{s['headings']} 正文:{s['paragraphs']} "
f"列表:{s['list_items']} 表格:{s['tables']} "
f"代码块:{s['code_blocks']} | 推断类型:{extracted['doc_hint']}")
FILE:reformat.py
"""
reformat.py — 一键重排版:读取任意 .docx → 输出规范排版的新 .docx
用法:
python3 reformat.py input.docx [output.docx] [--type TECH_MANUAL] [--dry-run]
参数:
input.docx 输入文件(任意 Word 文档)
output.docx 输出文件(可选,默认为 input_reformatted.docx)
--type 强制指定文档类型(可选,默认自动推断)
--dry-run 只打印提取的 Markdown,不生成文档(用于验证提取结果)
--show-md 生成文档后也打印 Markdown
OpenClaw 集成用法(Skill 内调用):
from reformat import reformat
result = reformat("input.docx", "output.docx", doc_type="TECH_MANUAL")
"""
import sys
import os
import argparse
from pathlib import Path
_SKILL_DIR = Path(__file__).parent
sys.path.insert(0, str(_SKILL_DIR))
from docx_reader import extract, to_markdown, summary
from converter import DocxConverter, DOC_TYPE_MAP
# ══════════════════════════════════════════════════════════════
# 核心:提取内容 → 构建 DocxConverter → 输出
# ══════════════════════════════════════════════════════════════
def reformat(input_path: str,
output_path: str = None,
doc_type: str = None,
dry_run: bool = False,
show_md: bool = False) -> dict:
"""
读取 input_path (.docx),重新排版后输出到 output_path。
返回 dict:
success bool
output 输出路径
doc_type 使用的文档类型
stats 提取统计
markdown 生成的 Markdown(show_md=True 时)
"""
input_path = Path(input_path)
if not input_path.exists():
raise FileNotFoundError(f"文件不存在:{input_path}")
# 默认输出路径
if output_path is None:
output_path = input_path.parent / (input_path.stem + "_reformatted.docx")
output_path = Path(output_path)
# ── 第一步:提取 ────────────────────────────────────────
print(f"📖 读取:{input_path.name}")
extracted = extract(str(input_path))
print(f" 提取结果:{summary(extracted)}")
# ── 第二步:确定文档类型 ────────────────────────────────
if doc_type is None:
doc_type = extracted["doc_hint"]
print(f" 自动推断类型:{doc_type}")
else:
print(f" 指定类型:{doc_type}")
# ── 第三步:构建 DocxConverter 并填充内容 ───────────────
conv = DocxConverter(doc_type)
blocks = extracted["blocks"]
# 标题
title_block = next((b for b in blocks if b["type"]=="heading" and b["level"]==0), None)
if title_block:
conv.title(title_block["text"])
elif extracted["title"]:
conv.title(extracted["title"])
# 收集副标题(第一个 level=1 之前的正文,通常是副标题/简介)
first_h1_idx = next((i for i,b in enumerate(blocks) if b["type"]=="heading" and b["level"]==1), len(blocks))
pre_blocks = [b for b in blocks[:first_h1_idx] if b["type"]=="body" and b.get("level",0)==0]
if pre_blocks:
conv.subtitle(pre_blocks[0]["text"])
# 主体内容
i = 0
while i < len(blocks):
b = blocks[i]
t = b["type"]
if t == "heading":
lvl = b["level"]
text = b["text"]
if lvl == 0:
i += 1; continue # 已处理
elif lvl == 1: conv.h1(text)
elif lvl == 2: conv.h2(text)
elif lvl == 3: conv.h3(text)
else: conv.h4(text)
elif t == "body":
text = b["text"]
# 跳过已作为副标题处理的段落
if i < first_h1_idx and text == (pre_blocks[0]["text"] if pre_blocks else None):
i += 1; continue
# 检测警告/注意段落
if re.match(r'^(注意|警告|警示|⚠|注|Warning|Caution)[::!!]', text):
conv.warning(text)
# 检测摘要
elif re.match(r'^摘要[::]', text):
# 提取关键词(下一个 block 如果是"关键词:"开头)
kw = None
if i+1 < len(blocks) and re.match(r'^关键词[::]', blocks[i+1].get("text","")):
kw = blocks[i+1]["text"]
i += 1
conv.abstract(re.sub(r'^摘要[::]','',text).strip(), keywords=kw)
# 检测落款/签名行
elif re.match(r'.*(签字|盖章|日期|年.*月.*日|签名|落款)', text) and len(text) < 60:
conv.signature_block(text)
else:
conv.body(text)
elif t == "bullet":
conv.bullet(b["text"], level=b.get("level",0))
elif t == "numbered":
conv.numbered(b["text"], level=b.get("level",0))
elif t == "code":
# 尝试识别语言
lang = _detect_lang(b["text"])
conv.code_block(b["text"], lang=lang)
elif t == "table":
# 检测前一个是否为表题
caption = None
if i > 0 and blocks[i-1]["type"] == "body":
prev = blocks[i-1]["text"]
if re.match(r'^表\d|^Table', prev):
caption = prev
conv.table(b["headers"], b["rows"], caption=caption)
elif t == "divider":
conv.divider()
i += 1
# ── Dry run:只打印 Markdown ────────────────────────────
md = conv.to_markdown()
if dry_run:
print("\n" + "─"*60)
print("Markdown 预览(dry-run):")
print("─"*60)
print(md)
return {"success": True, "output": None, "doc_type": doc_type,
"stats": extracted["stats"], "markdown": md}
if show_md:
print("\n" + "─"*60 + "\nMarkdown:\n" + "─"*60)
print(md[:2000] + ("..." if len(md)>2000 else ""))
# ── 第四步:生成输出 ────────────────────────────────────
print(f"\n🔄 排版中 → {output_path.name}")
conv.save(str(output_path))
return {
"success": True,
"output": str(output_path),
"doc_type": doc_type,
"stats": extracted["stats"],
"markdown": md if show_md else None,
}
import re
def _detect_lang(code: str) -> str:
"""简单代码语言检测"""
if re.search(r'\bimport\b|\bdef\b|\bclass\b|\bprint\b\(', code): return "python"
if re.search(r'\bfunction\b|\bconst\b|\blet\b|\bvar\b|\bconsole\.', code): return "javascript"
if re.search(r'\bpublic\b|\bprivate\b|\bvoid\b|\bString\b', code): return "java"
if re.search(r'^\s*#include|^\s*int main', code, re.M): return "c"
if re.search(r'\bSELECT\b|\bFROM\b|\bWHERE\b', code, re.I): return "sql"
if re.search(r'^\s*\$|^\s*apt|^\s*pip|^\s*conda|^\s*git', code, re.M): return "bash"
return ""
# ══════════════════════════════════════════════════════════════
# CLI 入口
# ══════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser(
description="重排版 Word 文档:读取任意 .docx,输出规范排版的新 .docx",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python3 reformat.py report.docx # 自动推断类型
python3 reformat.py report.docx out.docx --type GOV_DOC # 指定类型
python3 reformat.py report.docx --dry-run # 只预览提取结果
python3 reformat.py report.docx --show-md # 生成并显示 Markdown
支持的文档类型:
GOV_DOC / GOV_JUDICIAL / BUSINESS_CONTRACT / BUSINESS_TENDER /
BUSINESS_PLAN / ACADEMIC_PAPER / ACADEMIC_LESSON / TECH_SRS /
TECH_MANUAL / MEDICAL_RECORD / MEDICAL_DRUG / MARKETING_PLAN /
MARKETING_ANALYSIS / LEGAL_OPINION / LEGAL_LITIGATION /
FINANCE_REPORT / ENGINEERING_DOC / GENERAL_DOC
"""
)
parser.add_argument("input", help="输入 .docx 文件路径")
parser.add_argument("output", nargs="?", help="输出 .docx 文件路径(可选)")
parser.add_argument("--type", dest="doc_type", default=None,
help="强制指定文档类型(可选,默认自动推断)")
parser.add_argument("--dry-run", action="store_true",
help="只打印提取的 Markdown,不生成文档")
parser.add_argument("--show-md", action="store_true",
help="生成文档后也打印 Markdown 内容")
args = parser.parse_args()
try:
result = reformat(
input_path = args.input,
output_path = args.output,
doc_type = args.doc_type,
dry_run = args.dry_run,
show_md = args.show_md,
)
if result["success"] and result["output"]:
print(f"\n✅ 完成!输出:{result['output']}")
print(f" 类型:{result['doc_type']} | 统计:{result['stats']}")
except Exception as e:
print(f"\n❌ 错误:{e}")
sys.exit(1)
if __name__ == "__main__":
main()
生成交互式思维导图,支持从文本/摘要构建层级结构,直接输出 HTML、PNG、JPG、SVG、PDF、XMind 格式。所有图片格式仅需 pillow(pip install pillow),无需任何系统级C库。当用户要求生成思维导图、脑图、可视化结构图时调用。
---
name: mindmap
description: 生成交互式思维导图,支持从文本/摘要构建层级结构,直接输出 HTML、PNG、JPG、SVG、PDF、XMind 格式。所有图片格式仅需 pillow(pip install pillow),无需任何系统级C库。当用户要求生成思维导图、脑图、可视化结构图时调用。
user-invocable: true
metadata: {"openclaw": {"requires": {"bins": ["python3"], "pip": ["pillow"]}}}
---
# Mind Map Generator — 思维导图生成器
## 触发时机
- 用户说"生成思维导图"、"画脑图"、"做一个思维导图"
- 用户总结文章/内容后说"帮我做成思维导图"
- 用户说"把这些内容可视化"、"帮我梳理结构"
- 用户说"导出为 XMind / PNG / JPG / PDF"等格式
---
## 输出格式与依赖
**所有平台(Windows / macOS / Linux)统一依赖,只需 `pip install pillow`:**
| 格式 | 说明 | 依赖 |
|------|------|------|
| `html` | **默认**。交互式网页,支持拖拽/折叠/缩放/右键编辑/8种布局切换 | 无 |
| `svg` | 矢量图,无限缩放不失真 | 无 |
| `xmind` | XMind 8 / 2020+ 格式,可继续编辑 | 无 |
| `png` | 高清位图,适合嵌入文档/PPT/分享 | pillow |
| `jpg` | JPEG 位图,体积小 | pillow |
| `pdf` | PDF 文档 | pillow |
> **不需要安装任何系统级 C 库。** 如果 Pillow 未安装,脚本会**自动执行 `pip install pillow`**。
### 首次安装(一行命令)
```bash
pip install pillow
```
---
## 推荐的多格式输出策略
用户要求生成思维导图时,**默认同时输出 HTML + PNG + XMind 三种格式**,除非用户明确指定只要某种格式:
```bash
DATA='{
"central": "核心洞见",
"branches": [
{"label": "🔬 维度1", "color": "#4A90D9", "children": ["证据A", "证据B"]}
]
}'
# 1. HTML(交互式,浏览器查看/编辑)
python3 {baseDir}/generate_mindmap.py \
--title "标题" --format html \
--output ~/.openclaw/workspace/标题.html --data "$DATA"
# 2. PNG(高清图片,直接分享)
python3 {baseDir}/generate_mindmap.py \
--title "标题" --format png --scale 2.0 \
--output ~/.openclaw/workspace/标题.png --data "$DATA"
# 3. XMind(专业思维导图软件中编辑)
python3 {baseDir}/generate_mindmap.py \
--title "标题" --format xmind \
--output ~/.openclaw/workspace/标题.xmind --data "$DATA"
```
参数说明:
- `--format html/png/jpg/svg/pdf/xmind`(默认 html)
- `--output` 输出路径(可省略,默认保存到 `~/.openclaw/workspace/`)
- `--scale` PNG/JPG/PDF 的像素密度(默认 2.0,建议 1.0–3.0)
- `--quality` JPG 质量 1–100(默认 92)
---
## 认知科学基础:为什么这样做思维导图
以下原则基于四大认知理论,指导 AI 生成高质量思维导图:
### 放射性思考(Buzan)— 结构原则
Tony Buzan 提出思维导图模拟大脑神经元的放射状结构:从中心向外发散,每条分支是一个独立的联想链。
**对生成的要求:** 中心节点是唯一的焦点,所有分支从它辐射而出,不存在"并列的两个中心"。
### 双重编码理论(Paivio)— 记忆原则
Allan Paivio 发现:同时通过语言通道和视觉通道编码的信息,记忆率提升约 32%。具体词(可想象的)比抽象词记忆效果好得多。
**对生成的要求:**
1. **每个主分支必须带 emoji**(激活视觉通道)—— 如 `"🔬 技术演进"` 而非 `"技术演进"`
2. **优先使用具体词**而非抽象概括 —— 如 `"训练成本降至 6 万"` 而非 `"成本下降"`
3. **颜色即编码** —— 每个分支的颜色应与语义一致,同色系 = 同语义域
### 认知负荷与米勒定律(Miller)— 容量原则
工作记忆同时处理的信息块为 7±2 个。Beel & Langer 对 19,379 张思维导图的实证研究发现:典型思维导图平均 31 个节点,每节点 1-3 个词。
**对生成的硬约束:**
- 主分支 **3–6 个**(不超过 7)
- 每个分支的子节点 **2–5 个**
- 每个节点 **4–12 字**,叶节点不超过 15 字
- **全图总节点数 ≤ 40 个**(超过则拆分为多张图或合并子节点)
- 全图最多 **4 层**深度
### 语义网络(Quillian & Collins)— 关联原则
知识在大脑中以语义网络形式存储:概念是节点,关系是连线。**节点间的距离代表语义距离**——相关的概念应该在视觉上靠近。
**对生成的要求:** 语义相近的子节点应聚合在同一个分支下(聚类原则),而不是按原文出现顺序排列。
---
## 第一步:确定中心节点(最重要)
中心节点决定整张图的质量。它是**核心洞见**,不是话题标签。
**错误示范(话题标签):**
- ❌ "AI 发展趋势"
- ❌ "产品发布计划"
- ❌ "Python 学习"
**正确示范(核心洞见):**
- ✅ "AI 正从工具变为协作者"
- ✅ "产品成功 = 用户价值 × 执行速度"
- ✅ "Python 的价值在于生态而非语法"
**判断方法:**
1. 问自己"关于这个内容,最重要的一件事是什么?" 答案就是中心节点。
2. 如果中心节点换成另一个话题,现有分支还能成立 → 说明太泛,需要更精确。
---
## 第二步:设计主分支(维度而非章节)
主分支是"理解中心洞见的独立视角",不是原文的章节目录。
### 按内容类型选择维度框架
| 内容类型 | 分支维度框架 | 推荐布局 |
|----------|-------------|----------|
| 分析/研究类 | 是什么 · 为什么 · 怎么做 · 结果如何 | ⇆ 左右均衡 |
| 问题解决类 | 根因 · 影响 · 方案 · 评估 | 🐟 鱼骨图 |
| 学习知识类 | 核心概念 · 运作机制 · 适用场景 · 常见误区 | ⇆ 左右均衡 |
| 产品/项目类 | 目标 · 策略 · 执行 · 风险 | → 树形 |
| 比较分析类 | 相同点 · 差异点 · 各自优势 · 选择建议 | ⇆ 左右均衡 |
| 叙事/报告类 | 背景 · 发现 · 影响 · 行动 | → 树形 |
| 历史/阶段/流程 | 按时间或步骤顺序排列 | ⏩ 时间线 |
| 概念分解/归类 | 整体 → 部分1 + 部分2 + ... | } 括弧图 |
| **头脑风暴/创意发散** | **自由联想,不预设框架** | **✶ 辐射 或 ⚡ 力导向** |
### 分支设计原则
**原则 1:分支之间必须相互独立**(语义网络的聚类原则)
每个分支回答关于中心节点的一个不同问题。语义相近的内容聚合在同一分支下。
**原则 2:所有分支必须共同服务于中心节点**
反问:"去掉这个分支,对理解中心节点有损失吗?" 无损失 → 该分支冗余,删除。
**原则 3:分支数量 3–6 个**(米勒定律)
不以"原文有几节"为准。超过 7 个分支时必须合并。
**原则 4:同级分支的抽象层次必须一致**
❌ 错误:同级出现"技术原理"(抽象)和"GPT-4"(具体实例)
✅ 正确:同级都是维度("技术路线"、"商业模式"、"社会影响")
### 每个主分支必须带 emoji(双重编码)
emoji 的作用是激活视觉编码通道,使分支在记忆中形成"图像锚点"。选择规则:
- emoji 应直观反映该维度的语义核心
- 同一张图内 emoji 不重复
- 置于 label 最前面:`"🔬 技术演进"` `"💰 商业模式"` `"⚠️ 风险挑战"`
| 语义域 | 推荐 emoji |
|--------|-----------|
| 技术/科学 | 🔬 🧪 ⚙️ 🔧 💻 |
| 商业/金融 | 💰 📈 🏦 💼 🎯 |
| 人物/用户 | 👤 👥 🧑💻 🎓 |
| 风险/问题 | ⚠️ 🚧 ❗ 🔴 |
| 趋势/时间 | 📅 🔮 📊 🕐 |
| 成果/价值 | ✅ 🏆 💡 ⭐ |
| 流程/步骤 | 🔄 📋 🗺️ 🛤️ |
| 背景/历史 | 📖 🏛️ 🌍 🗂️ |
---
## 第三步:填充子节点(证据而非子话题)
子节点不是"这个分支下还有哪些话题",而是"**让这个分支成立的具体依据**"。
### 优先使用具体词(双重编码)
具体的、可想象的词比抽象词记忆效果好 1.5-2 倍(Paivio 实验数据)。
| 子节点类型 | 示例 | 作用 |
|------------|------|------|
| 具体机制 | "注意力机制使模型聚焦关键词" | 解释"为什么" |
| 量化数据 | "GPT-4 训练成本超 1 亿美元" | 使声明可信 |
| 典型案例 | "Copilot 使代码效率提升 55%" | 使抽象具体 |
| 对比参照 | "vs 传统搜索:主动生成 vs 被动检索" | 揭示差异 |
| 行动指南 | "先用小数据集验证再扩规模" | 指导实践 |
| 关键限制 | "幻觉率随任务复杂度非线性上升" | 防止误用 |
### 层级深度规则
```
中心节点(1 个)
└── 主分支(3–6 个) ← 理解维度,带 emoji,抽象词组
└── 子节点(2–5 个) ← 支撑证据,具体词组或短句
└── 叶节点(可选,1–3 个) ← 最具体的事实/数据/步骤
```
- 全图最多 4 层,**总节点 ≤ 40 个**
- 每个主分支至少 2 个子节点(1 个说明未充分挖掘)
- 叶节点不超过 3 个(太多说明需要再拆一层)
---
## 第四步:节点文字规范
| 规则 | 正确 | 错误 |
|------|------|------|
| 使用原文关键词,不过度意译 | "联邦学习" | "一种保护数据的分布式方法" |
| 动词短语比名词堆砌更有力 | "降低推理延迟 40%" | "推理延迟优化相关内容" |
| 长度 4–12 字 | "本地训练不传原始数据" | 过长的完整句子 |
| 并列内容拆成独立节点 | 三个节点:"医疗" / "教育" / "金融" | 一个节点:"医疗教育金融" |
| 越深层越具体(认知负荷递减) | 叶节点写具体数字/步骤/案例 | 叶节点写抽象标签 |
---
## 第五步:质量自查(生成后必检)
生成 JSON 后,逐条检查,不合格立即修改:
**容量检查(米勒定律):**
- [ ] 总节点数是否 ≤ 40?(超过则合并或拆分)
- [ ] 主分支是否 3–6 个?
- [ ] 每个节点是否 ≤ 12 字?
**必要性检查(认知负荷):**
- [ ] 删去任意一个主分支,对理解中心节点的损失是否明显?(不明显 → 删除该分支)
- [ ] 每个子节点是否使其父分支更可信/更具体?(否 → 删除或替换)
- [ ] 是否存在"正确的废话"节点?(如"需要进一步研究" → 删除)
**双重编码检查:**
- [ ] 每个主分支是否带了 emoji?
- [ ] 叶节点是否用了具体的、可想象的词?(抽象标签 → 替换为具体事实)
- [ ] 颜色是否与语义一致?(同类维度同色系)
**结构检查(放射性思考):**
- [ ] 中心节点是核心洞见还是话题标签?
- [ ] 同级分支的抽象层次是否一致?
- [ ] 是否有只含 1 个子节点的分支?(合并或补充)
---
## 第六步:构造 JSON 并执行
> **⚠️ 强制规则:每个主分支的 label 必须以 emoji 开头。**
> 格式:`"label": "🔬 技术演进"` —— emoji + 空格 + 文字。
> 这是基于双重编码理论的硬性要求,不可省略。
```json
{
"central": "核心洞见(不超过 15 字)",
"branches": [
{
"label": "🔬 维度1(emoji 必须有)",
"color": "#4A90D9",
"children": [
"具体证据A(可想象的词)",
"量化数据B",
{
"label": "需要展开的证据C",
"children": ["最具体的事实1", "最具体的事实2"]
}
]
}
]
}
```
**颜色分配建议(按分支语义选色):**
| 颜色 | 适用维度 | 搭配 emoji |
|------|----------|-----------|
| `#4A90D9` 蓝 | 机制/原理/方法论 | 🔬 ⚙️ 🧪 |
| `#27AE60` 绿 | 成果/价值/应用 | ✅ 💡 🏆 |
| `#E86C3A` 橙 | 问题/挑战/风险 | ⚠️ 🚧 ❗ |
| `#9B59B6` 紫 | 背景/趋势/上下文 | 📖 🔮 🌍 |
| `#F39C12` 黄 | 资源/工具/要素 | 🔧 📦 💼 |
| `#1ABC9C` 青 | 流程/步骤/路径 | 🔄 🛤️ 📋 |
| `#E74C3C` 红 | 警告/限制/禁忌 | 🔴 ⛔ 🚫 |
脚本路径:`{baseDir}/generate_mindmap.py`
---
## 完整示例
```bash
DATA='{
"central": "技术壁垒正被成本竞争取代",
"branches": [
{
"label": "🔬 技术门槛变化", "color": "#4A90D9",
"children": [
{"label": "训练成本下降", "children": ["GPT-3 成本 460万刀", "同能力模型降至 6 万"]},
"开源模型追平闭源", "微调替代全量训练"
]
},
{
"label": "💰 主流盈利路径", "color": "#27AE60",
"children": [
"API 按 token 计费",
{"label": "垂直行业定制", "children": ["医疗合规要求高溢价", "法律文档审核替代人工"]},
"订阅制 Pro 用户"
]
},
{
"label": "⚔️ 竞争驱动力", "color": "#E86C3A",
"children": ["价格战压缩利润空间", "算力即竞争壁垒", "数据飞轮强者愈强"]
},
{
"label": "📜 监管不确定性", "color": "#9B59B6",
"children": [
{"label": "欧美监管分歧", "children": ["EU AI Act 强制备案", "美国行业自律为主"]},
"中国实名制与内容审核", "合规成本影响中小玩家"
]
}
]
}'
python3 {baseDir}/generate_mindmap.py --title "大模型商业化" --format html --output ~/.openclaw/workspace/大模型商业化.html --data "$DATA"
python3 {baseDir}/generate_mindmap.py --title "大模型商业化" --format png --output ~/.openclaw/workspace/大模型商业化.png --data "$DATA"
python3 {baseDir}/generate_mindmap.py --title "大模型商业化" --format xmind --output ~/.openclaw/workspace/大模型商业化.xmind --data "$DATA"
```
**执行成功后告知用户(必须包含完整的绝对路径,方便用户查找文件):**
> 思维导图已生成三种格式,保存在以下位置:
> - **HTML**:`<绝对路径>/大模型商业化.html` —— 用浏览器打开,支持交互查看、编辑节点、切换 8 种布局
> - **PNG**:`<绝对路径>/大模型商业化.png` —— 高清图片,可直接嵌入文档或分享
> - **XMind**:`<绝对路径>/大模型商业化.xmind` —— 可在 XMind 软件中打开继续编辑
>
> 其中 `<绝对路径>` 替换为脚本实际输出的路径(从脚本的 stdout 中获取,格式为 `[mindmap] ✅ HTML → /完整/路径/文件名.html`)。
---
## 内置示例文件
`{baseDir}/examples/` 目录中包含可直接打开的示例:
| 文件 | 说明 |
|------|------|
| `examples/ai_trends.html` | AI 发展趋势(5 主分支,3 层嵌套) |
| `examples/product_launch.html` | 产品发布计划 |
| `examples/python_learning.html` | Python 学习路径 |
FILE:generate_mindmap.py
#!/usr/bin/env python3
"""
generate_mindmap.py — OpenClaw Mind Map Generator v5
Layout : Balanced left-right tree (XMind style)
- branches split evenly left / right
- each side laid out as a vertical tree
- elbow connectors (horizontal → vertical)
- smooth Bezier from root to first-level branches
Features:
- Click to collapse / expand
- Drag node to reposition; right/bottom edge handles to resize
- Pan, zoom toward cursor, reset view
- In-browser export: SVG / PNG / JPG / PDF / XMind
- Python-side export: --format html|svg|png|jpg|pdf|xmind
Usage:
python3 generate_mindmap.py --title "Topic" --output /tmp/map.html \\
--data '{"central":"...","branches":[...]}' [--format html]
"""
import argparse, json, math, sys, zipfile, io, uuid, os, platform, subprocess
from datetime import datetime
from pathlib import Path
def _ensure_pillow():
"""Try to import Pillow; if missing, auto-install via pip and retry."""
try:
from PIL import Image # noqa: F401
return True
except ImportError:
pass
print("[mindmap] Pillow not found, installing automatically …", file=sys.stderr)
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "pillow", "--quiet",
"--disable-pip-version-check", "--break-system-packages"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except (subprocess.CalledProcessError, FileNotFoundError):
# Retry without --break-system-packages (older pip)
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "pillow", "--quiet",
"--disable-pip-version-check"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
print("[mindmap] ⚠ Auto-install failed. Please run manually:", file=sys.stderr)
print(" pip install pillow", file=sys.stderr)
return False
try:
from PIL import Image # noqa: F401
print("[mindmap] ✅ Pillow installed successfully.", file=sys.stderr)
return True
except ImportError:
print("[mindmap] ⚠ Install succeeded but import still fails.", file=sys.stderr)
return False
# ─────────────────────────────────────────────────────────────────────────────
# Cross-platform path helpers
# ─────────────────────────────────────────────────────────────────────────────
def resolve_output(raw_path: str, fmt: str) -> str:
"""Expand ~, $HOME, %USERPROFILE%, create parent dirs, fix extension.
If the path is just a filename without directory (e.g. 'mindmap.png'),
it is placed under ~/.openclaw/workspace/ instead of the current dir.
"""
p = Path(os.path.expandvars(os.path.expanduser(raw_path)))
ext_map = {"html":".html","svg":".svg","png":".png",
"jpg":".jpg","pdf":".pdf","xmind":".xmind"}
expected = ext_map.get(fmt, "")
if expected and p.suffix.lower() != expected:
p = p.with_suffix(expected)
# If only a bare filename was given (no directory), use default workspace
if p.parent == Path(".") or str(p.parent) == ".":
workspace = Path.home() / ".openclaw" / "workspace"
workspace.mkdir(parents=True, exist_ok=True)
p = workspace / p.name
p.parent.mkdir(parents=True, exist_ok=True)
return str(p)
def default_output(fmt: str) -> str:
"""Return a sensible default output path for the current OS.
Default: ~/.openclaw/workspace/ (cross-platform, auto-adapts to username)
- Windows: C:\\Users\\<username>\\.openclaw\\workspace\\
- macOS: /Users/<username>/.openclaw/workspace/
- Linux: /home/<username>/.openclaw/workspace/
"""
ext_map = {"html":".html","svg":".svg","png":".png",
"jpg":".jpg","pdf":".pdf","xmind":".xmind"}
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
ext = ext_map.get(fmt, ".html")
name = f"mindmap_{ts}{ext}"
workspace = Path.home() / ".openclaw" / "workspace"
workspace.mkdir(parents=True, exist_ok=True)
return str(workspace / name)
DEFAULT_COLORS = [
"#4A90D9","#E86C3A","#27AE60","#9B59B6",
"#E74C3C","#F39C12","#1ABC9C","#E91E63",
"#00BCD4","#8BC34A",
]
# ── Visual config per depth ────────────────────────────────────────────────────
# depth 0 = central, 1 = branch, 2 = leaf, 3 = sub-leaf
CFG = [
dict(h=48, fs=16, fw="bold", rx=12, px=32, min_w=180),
dict(h=38, fs=13, fw="bold", rx= 8, px=24, min_w=110),
dict(h=30, fs=12, fw="normal", rx= 6, px=18, min_w= 80),
dict(h=26, fs=11, fw="normal", rx= 5, px=16, min_w= 72),
]
# Horizontal gap between node right-edge and children column
H_GAP = [0, 64, 48, 40]
# Vertical gap between sibling nodes
V_GAP = [0, 20, 12, 8]
FONT_STACK = "PingFang SC,Hiragino Sans GB,Microsoft YaHei,Segoe UI,Arial,sans-serif"
# ─────────────────────────────────────────────────────────────────────────────
# CLI
# ─────────────────────────────────────────────────────────────────────────────
def parse_args():
p = argparse.ArgumentParser(
description="OpenClaw Mind Map Generator — cross-platform (macOS / Linux / Windows)"
)
p.add_argument("--title", required=True, help="Mind map title")
p.add_argument("--output", default=None, help="Output file path (default: ~/.openclaw/workspace/mindmap_<ts>.<ext>)")
p.add_argument("--data", required=True, help="JSON string describing the mind map structure")
p.add_argument("--format", default="html",
choices=["html","svg","png","jpg","pdf","xmind"],
help="Output format (default: html)")
p.add_argument("--scale", type=float, default=2.0,
help="Pixel density for PNG/JPG/PDF (default: 2.0)")
p.add_argument("--quality", type=int, default=92,
help="JPEG quality 1-100 (default: 92)")
return p.parse_args()
# ─────────────────────────────────────────────────────────────────────────────
# Tree helpers
# ─────────────────────────────────────────────────────────────────────────────
def normalize_node(node):
if isinstance(node, str):
return {"label": node, "children": []}
if isinstance(node, dict):
node.setdefault("children", [])
node["children"] = [normalize_node(c) for c in node["children"]]
return node
return {"label": str(node), "children": []}
def build_tree(data):
if "central" not in data:
raise ValueError("JSON must contain a 'central' key.")
branches = []
for i, b in enumerate(data.get("branches", [])):
nb = normalize_node(b)
nb.setdefault("color", DEFAULT_COLORS[i % len(DEFAULT_COLORS)])
# ── Auto-inject emoji if branch label doesn't start with one ──
_auto_inject_emoji(nb, i)
branches.append(nb)
return {"central": data["central"], "branches": branches}
# ── Emoji auto-injection (Dual-Coding Theory) ────────────────────────────
# If the AI didn't add emoji to branch labels, the script selects an emoji
# by matching the label text against a semantic keyword dictionary.
# This ensures the emoji visually represents the *meaning* of the branch,
# not just its color.
# Semantic keyword → emoji mapping (Chinese + English, ordered by specificity)
# More specific keywords must come before general ones.
_SEMANTIC_EMOJI = [
# ── 人物/角色 ──
(["人物", "角色", "团队", "成员", "员工", "用户", "画像", "character", "people", "team", "user", "member", "staff", "player", "persona"], "👥"),
(["作者", "作家", "创始人", "author", "writer", "founder", "creator"], "✍️"),
(["领导", "管理层", "CEO", "leader", "management", "executive"], "👔"),
(["客户", "顾客", "消费者", "受众", "customer", "client", "consumer", "audience"], "🧑💼"),
(["合作", "伙伴", "联盟", "协作", "partner", "collaborat", "alliance", "cooperat"], "🤝"),
# ── 时间/历史 ──
(["历史", "背景", "起源", "演变", "发展史", "history", "background", "origin", "evolution", "heritage"], "🏛️"),
(["时间", "阶段", "时期", "年代", "时间线", "timeline", "period", "phase", "era", "date", "schedule"], "📅"),
(["未来", "趋势", "展望", "预测", "前景", "future", "trend", "forecast", "outlook", "vision"], "🔮"),
# ── 技术/科学 ──
(["技术", "科技", "研发", "算法", "tech", "technology", "algorithm", "engineering", "R&D"], "🔬"),
(["AI", "人工智能", "机器学习", "深度学习", "模型", "artificial intelligence", "machine learning", "neural", "GPT", "LLM"], "🤖"),
(["代码", "编程", "开发", "软件", "code", "programming", "software", "develop", "API", "debug"], "💻"),
(["数据", "数据库", "统计", "指标", "data", "database", "analytics", "statistics", "metric", "KPI"], "📊"),
(["网络", "互联网", "通信", "社交媒体", "社交", "social media", "network", "internet", "communication", "web", "online"], "🌐"),
(["安全", "加密", "隐私", "防护", "security", "encryption", "privacy", "cyber", "protect"], "🔒"),
# ── 商业/金融 ──
(["商业", "盈利", "收入", "营收", "利润", "变现", "business", "revenue", "profit", "income", "monetiz"], "💰"),
(["市场", "营销", "推广", "品牌", "口碑", "传播", "market", "marketing", "brand", "promotion", "advertis", "PR"], "📈"),
(["销售", "卖点", "转化", "获客", "增长", "拉新", "sale", "conver", "acquisition", "growth", "retain", "funnel"], "📊"),
(["竞争", "对手", "竞品", "壁垒", "competi", "rival", "barrier"], "⚔️"),
(["成本", "费用", "预算", "投资", "融资", "cost", "budget", "invest", "expense", "funding", "capital"], "💵"),
(["价格", "定价", "收费", "报价", "price", "pricing", "fee", "charge", "quota"], "🏷️"),
(["战略", "策略", "规划", "布局", "strategy", "strategic", "planning", "roadmap"], "🎯"),
(["产品", "功能", "特性", "需求", "product", "feature", "function", "requirement", "spec"], "📦"),
(["渠道", "分发", "分销", "平台", "channel", "distribut", "platform"], "🔗"),
(["供应链", "供应", "采购", "库存", "procurement", "supply", "inventory", "sourcing", "vendor"], "🏭"),
(["运营", "运维", "运作", "operation", "ops", "maintain"], "⚙️"),
# ── 客户服务 ──
(["服务", "客服", "售后", "保障", "支持", "service", "support", "after-sale", "warranty", "helpdesk"], "🎧"),
(["体验", "满意度", "反馈", "口碑", "评价", "experience", "satisfaction", "feedback", "review", "NPS", "UX"], "⭐"),
(["留存", "复购", "忠诚", "黏性", "retention", "loyalty", "repeat", "churn", "stickiness"], "🔁"),
# ── 教育/学习 ──
(["学习", "教育", "课程", "培训", "教学", "learn", "education", "course", "training", "teach", "study"], "🎓"),
(["知识", "概念", "理论", "原理", "knowledge", "concept", "theory", "principle"], "📚"),
(["考试", "测试", "评估", "评价", "评分", "exam", "test", "assessment", "evaluation", "grading"], "📝"),
(["启蒙", "早教", "入门", "基础", "beginner", "fundamental", "basic", "introduct", "primer"], "🌟"),
# ── 职业/求职 ──
(["职业", "求职", "面试", "简历", "career", "job", "interview", "resume", "CV", "hiring", "recruit"], "💼"),
(["薪资", "工资", "薪酬", "待遇", "salary", "wage", "compensation", "pay", "bonus"], "💳"),
(["晋升", "升职", "成长", "发展", "职级", "promotion", "advancement", "career path", "growth"], "📶"),
(["技能", "能力", "技巧", "skill", "ability", "competenc", "expertise", "proficienc"], "🎯"),
# ── 文学/艺术 ──
(["作品", "文学", "小说", "著作", "文章", "writing", "literature", "novel", "book", "article"], "📖"),
(["艺术", "风格", "美学", "art", "style", "aesthetic"], "🎨"),
(["音乐", "歌曲", "music", "song", "melody", "album"], "🎵"),
(["电影", "视频", "影视", "film", "movie", "video", "cinema"], "🎬"),
(["文化", "传统", "文明", "民俗", "culture", "tradition", "civilization", "folk"], "🌍"),
(["摄影", "拍摄", "相机", "镜头", "photo", "camera", "lens", "shoot", "image"], "📷"),
(["设计", "UI", "UX", "排版", "视觉", "design", "layout", "visual", "graphic", "typograph"], "🎨"),
# ── 结构/组织 ──
(["结构", "组织", "架构", "框架", "structure", "organization", "framework", "architect"], "🏗️"),
(["流程", "步骤", "过程", "方法", "工艺", "process", "step", "procedure", "method", "workflow", "SOP"], "🔄"),
(["分类", "类型", "类别", "种类", "category", "type", "classification", "kind", "taxonomy"], "🗂️"),
(["情节", "故事", "叙事", "剧情", "plot", "story", "narrative", "chapter"], "📜"),
(["标准", "规范", "验收", "准则", "质检", "standard", "specification", "criteria", "QA", "QC", "inspect", "quality"], "✅"),
# ── 问题/风险 ──
(["问题", "挑战", "困难", "障碍", "痛点", "problem", "challenge", "difficult", "obstacle", "issue", "pain point"], "⚠️"),
(["风险", "危险", "威胁", "risk", "danger", "threat", "hazard"], "🚨"),
(["限制", "缺点", "不足", "局限", "短板", "limitation", "disadvantage", "weakness", "restrict", "drawback"], "🚧"),
(["监管", "法规", "合规", "政策", "法律", "regulat", "compliance", "policy", "law", "legal", "govern"], "⚖️"),
# ── 成果/价值 ──
(["成果", "成就", "成功", "价值", "优势", "亮点", "achievement", "success", "value", "advantage", "benefit", "highlight"], "🏆"),
(["目标", "愿景", "使命", "goal", "objective", "mission", "OKR", "KR"], "🎯"),
(["创新", "突破", "改进", "优化", "innovati", "breakthrough", "improve", "optimiz"], "💡"),
(["影响", "效果", "作用", "成效", "impact", "effect", "influence", "outcome", "result"], "💫"),
(["应用", "实践", "案例", "场景", "用途", "application", "practice", "case", "scenario", "use case", "usage"], "🛠️"),
# ── 资源/工具 ──
(["资源", "工具", "设备", "器材", "装备", "resource", "tool", "equipment", "instrument", "gear", "device"], "🔧"),
(["材料", "原料", "物料", "素材", "material", "ingredient", "raw material", "substance", "fabric"], "📦"),
(["环境", "生态", "自然", "绿色", "environment", "ecology", "nature", "climate", "green", "sustain"], "🌱"),
# ── 健康/医疗 ──
(["健康", "医疗", "疾病", "治疗", "症状", "health", "medical", "disease", "treatment", "clinical", "symptom", "diagnosis"], "🏥"),
(["营养", "饮食", "膳食", "维生素", "nutrition", "diet", "vitamin", "supplement", "calorie"], "🥗"),
(["疫苗", "免疫", "防疫", "vaccine", "immuniz", "prevention"], "💉"),
(["健身", "运动", "锻炼", "体能", "fitness", "workout", "exercise", "gym", "training"], "💪"),
(["减肥", "瘦身", "体重", "weight loss", "slim", "body fat"], "⚖️"),
(["睡眠", "休息", "作息", "sleep", "rest", "insomnia", "nap"], "😴"),
(["压力", "焦虑", "减压", "放松", "stress", "anxiety", "relax", "mindful", "meditation"], "🧘"),
# ── 地理/旅游 ──
(["地理", "地点", "位置", "区域", "国家", "geography", "location", "region", "country", "city"], "🗺️"),
(["旅游", "旅行", "攻略", "出行", "度假", "travel", "trip", "tour", "vacation", "journey", "itinerary"], "✈️"),
(["住宿", "酒店", "民宿", "hotel", "accommodation", "hostel", "lodging", "Airbnb"], "🏨"),
(["景点", "景区", "名胜", "观光", "sight", "attraction", "landmark", "scenic", "destination"], "🏞️"),
(["交通", "物流", "运输", "出行", "transport", "logistics", "shipping", "commut", "transit"], "🚚"),
# ── 食物/烹饪 ──
(["食物", "美食", "菜肴", "菜谱", "烹饪", "做菜", "food", "cuisine", "cooking", "recipe", "meal", "dish", "chef"], "🍽️"),
# ── 家庭/育儿 ──
(["育儿", "孩子", "儿童", "宝宝", "亲子", "parent", "child", "baby", "kid", "toddler", "nurtur"], "👶"),
(["家庭", "家居", "家装", "居家", "family", "home", "household", "domestic"], "🏠"),
(["装修", "装饰", "翻新", "改造", "renovati", "decorat", "interior", "remodel", "furnish"], "🏠"),
# ── 宠物 ──
(["宠物", "猫", "狗", "喂养", "兽医", "pet", "cat", "dog", "vet", "animal", "breed", "groom"], "🐾"),
# ── 体育/运动 ──
(["体育", "赛事", "联赛", "比赛", "竞技", "sport", "league", "competition", "athletic", "tournament", "championship"], "⚽"),
# ── 法律/制度 ──
(["制度", "规则", "条例", "法案", "条款", "system", "rule", "regulation", "act", "institution", "clause"], "📜"),
# ── 情感/心理 ──
(["情感", "心理", "性格", "认知", "行为", "personality", "emotion", "psychology", "mental", "character", "cognitive", "behavior"], "🧠"),
# ── 建筑/空间 ──
(["建筑", "空间", "场所", "场地", "building", "space", "place", "architecture", "venue", "facility"], "🏗️"),
# ── 财务/理财 ──
(["理财", "投资", "基金", "股票", "债券", "保险", "finance", "invest", "fund", "stock", "bond", "insurance", "portfolio"], "💹"),
(["税", "税务", "纳税", "报税", "tax", "taxation", "fiscal"], "🧾"),
(["退休", "养老", "pension", "retire", "annuity"], "🏖️"),
(["资产", "财富", "净值", "asset", "wealth", "net worth", "estate", "property"], "🏦"),
# ── 制造/生产 ──
(["制造", "生产", "工厂", "产线", "manufactur", "production", "factory", "assembly", "fabricat"], "🏭"),
(["包装", "封装", "标签", "packaging", "labeling", "wrapping", "container"], "📦"),
(["检测", "测量", "检验", "校准", "detect", "measure", "inspect", "calibrat", "monitor"], "🔎"),
(["配方", "研发", "recipe", "formul", "R&D", "develop"], "🧪"),
# ── 概述/介绍/总结 ──
(["概述", "简介", "总结", "综述", "overview", "introduction", "summary", "abstract", "brief", "recap"], "📋"),
# ── 军事/战争 ──
(["战役", "战争", "军事", "战斗", "作战", "攻防", "battle", "war", "military", "combat", "warfare", "campaign"], "⚔️"),
# ── 思想/哲学 ──
(["思想", "哲学", "主题", "观点", "理念", "意识形态", "thought", "philosophy", "theme", "ideology", "idea", "viewpoint"], "💭"),
# ── 研究/学术 ──
(["研究", "学术", "流派", "论文", "学科", "实验", "research", "academic", "paper", "discipline", "experiment", "scholar"], "🔍"),
# ── 版本/变更 ──
(["版本", "变更", "更新", "迭代", "修订", "version", "update", "revision", "iteration", "changelog"], "📄"),
# ── 护理/日常 ──
(["护理", "保养", "维护", "保健", "日常", "care", "maintenance", "routine", "daily", "upkeep", "hygiene"], "🧴"),
# ── 沟通/表达 ──
(["沟通", "表达", "演讲", "谈判", "对话", "communicat", "express", "speech", "negotiat", "dialog", "present"], "🗣️"),
# ── 选择/推荐/对比 ──
(["选择", "推荐", "对比", "评测", "排名", "选型", "choose", "recommend", "compare", "review", "rank", "select", "pick", "best"], "🔖"),
# ── 指南/攻略/技巧 ──
(["指南", "攻略", "技巧", "诀窍", "要点", "秘诀", "guide", "tip", "trick", "hack", "how-to", "tutorial", "cheat sheet"], "📌"),
# ── 构图/光线 (摄影/视觉) ──
(["构图", "光线", "色彩", "曝光", "composit", "lighting", "exposure", "color", "tone", "contrast"], "🖼️"),
# ── 后期/编辑 ──
(["后期", "编辑", "修图", "剪辑", "渲染", "edit", "post-process", "retouch", "render", "montage"], "✂️"),
]
_FALLBACK_EMOJIS = ["📌", "📎", "🔹", "🔸", "▪️", "🔻", "🔺", "💠", "🔘", "📍"]
def _has_emoji_prefix(text):
"""Check if text starts with an emoji (Unicode emoji range)."""
if not text:
return False
cp = ord(text[0])
if cp >= 0x1F300: return True
if 0x2600 <= cp <= 0x27BF: return True
if 0x2300 <= cp <= 0x23FF: return True
if 0xFE00 <= cp <= 0xFEFF: return True
if 0x200D <= cp <= 0x200D: return True
if len(text) > 1:
cp2 = ord(text[1])
if cp2 >= 0x1F300 or cp2 == 0xFE0F or cp2 == 0x20E3:
return True
return False
def _match_emoji_by_semantic(label):
"""Match an emoji by scanning the label against the keyword dictionary."""
text = label.lower()
for keywords, emoji in _SEMANTIC_EMOJI:
for kw in keywords:
if kw.lower() in text:
return emoji
return None
def _auto_inject_emoji(branch, index):
"""Add semantically matched emoji prefix to branch label if missing."""
label = branch.get("label", "")
if _has_emoji_prefix(label):
return # already has emoji
# 1. Try semantic keyword matching
emoji = _match_emoji_by_semantic(label)
# 2. Fallback: use index-based neutral emoji
if not emoji:
emoji = _FALLBACK_EMOJIS[index % len(_FALLBACK_EMOJIS)]
branch["label"] = f"{emoji} {label}"
def measure_w(text, depth):
c = CFG[min(depth, len(CFG)-1)]
w = sum(c["fs"] * (0.92 if ord(ch) > 127 else 0.58) for ch in str(text))
return max(c["min_w"], w + c["px"] * 2)
_nid = [0]
def annotate(node, depth):
node["_id"] = "n" + str(_nid[0]); _nid[0] += 1
node["_depth"] = depth
node["_w"] = measure_w(node.get("label") or node.get("central", ""), depth)
node["_h"] = CFG[min(depth, len(CFG)-1)]["h"]
for ch in node.get("children", []): annotate(ch, depth + 1)
for b in node.get("branches", []): annotate(b, 1)
def flatten_tree(tree):
result, q = [], [tree]
while q:
n = q.pop(0); result.append(n)
for c in n.get("children", []) + n.get("branches", []): q.append(c)
return result
# ─────────────────────────────────────────────────────────────────────────────
# LAYOUT — Balanced left-right tree
#
# Right side: branch node → children stacked vertically to its right
# Left side: branch node → children stacked vertically to its left
# (node x = -(branch_x + branch_w/2 + gap + child_w/2))
#
# Positions are stored as node centres.
# ─────────────────────────────────────────────────────────────────────────────
def subtree_height(node):
"""Total vertical space this subtree needs (including V_GAP between siblings)."""
d = node["_depth"]
kids = node.get("children", [])
vg = V_GAP[min(d, len(V_GAP)-1)]
if not kids:
return node["_h"]
ch_total = sum(subtree_height(k) for k in kids) + vg * (len(kids) - 1)
return max(node["_h"], ch_total)
def layout_subtree(node, cx, cy, side, positions):
"""
Place `node` centred at (cx, cy).
Then place its children on `side` (+1 = right, -1 = left).
"""
positions[node["_id"]] = {"x": cx, "y": cy, "parent_id":
positions.get(node["_id"], {}).get("parent_id")}
kids = node.get("children", [])
if not kids:
return
d = node["_depth"]
vg = V_GAP[min(d+1, len(V_GAP)-1)]
hg = H_GAP[min(d+1, len(H_GAP)-1)]
# x-centre of children column
child_w = max(k["_w"] for k in kids)
child_cx = cx + side * (node["_w"] / 2 + hg + child_w / 2)
# total height block of children
heights = [subtree_height(k) for k in kids]
total_h = sum(heights) + vg * (len(kids) - 1)
# start y so the block is centred on cy
cur_y = cy - total_h / 2
for kid, h in zip(kids, heights):
kid_cy = cur_y + h / 2
positions[kid["_id"]] = {"x": child_cx, "y": kid_cy,
"parent_id": node["_id"]}
layout_subtree(kid, child_cx, kid_cy, side, positions)
cur_y += h + vg
def compute_layout(tree):
positions = {}
positions[tree["_id"]] = {"x": 0, "y": 0, "parent_id": None}
branches = tree.get("branches", [])
if not branches:
return positions
n = len(branches)
n_right = math.ceil(n / 2) # right side gets ceiling
n_left = n - n_right
right_branches = branches[:n_right]
left_branches = branches[n_right:]
root_h = CFG[0]["h"]
root_w = tree["_w"]
def place_side(side_branches, side):
"""Stack branches vertically, centred on root y=0."""
vg = V_GAP[1]
heights = [subtree_height(b) for b in side_branches]
total_h = sum(heights) + vg * (len(heights) - 1)
hg = H_GAP[1]
# branch column x-centre
branch_cx = side * (root_w / 2 + hg + (max(b["_w"] for b in side_branches) / 2 if side_branches else 0))
cur_y = -total_h / 2
for branch, h in zip(side_branches, heights):
bcy = cur_y + h / 2
positions[branch["_id"]] = {"x": branch_cx, "y": bcy,
"parent_id": tree["_id"]}
layout_subtree(branch, branch_cx, bcy, side, positions)
cur_y += h + vg
place_side(right_branches, 1)
place_side(left_branches, -1)
return positions
def bounding_box(positions, nodes, pad=60):
xs, ys = [], []
for n in nodes:
p = positions.get(n["_id"])
if not p: continue
xs += [p["x"] - n["_w"]/2, p["x"] + n["_w"]/2]
ys += [p["y"] - n["_h"]/2, p["y"] + n["_h"]/2]
if not xs:
return -pad, -pad, pad, pad
return min(xs)-pad, min(ys)-pad, max(xs)+pad, max(ys)+pad
def build_color_map(tree):
cmap = {tree["_id"]: "#7c8cf8"}
def fill(node, color):
cmap[node["_id"]] = color
for ch in node.get("children", []): fill(ch, color)
for b in tree.get("branches", []): fill(b, b.get("color", "#888"))
return cmap
# ─────────────────────────────────────────────────────────────────────────────
# Edge path helpers
# ─────────────────────────────────────────────────────────────────────────────
def edge_path(px, py, pw, cx, cy, cw, depth):
"""
All depths: smooth cubic Bezier from parent side-edge to child side-edge.
Matches the HTML interactive version (edgePath0) — elegant S-curves
instead of hard elbow connectors.
"""
if cx >= px: # right side
x1, x2 = px + pw/2, cx - cw/2
else: # left side
x1, x2 = px - pw/2, cx + cw/2
# Nearly horizontal alignment → straight line
if abs(cy - py) < 3:
return f"M{x1:.1f},{py:.1f} L{x2:.1f},{cy:.1f}"
# Tension: depth-1 uses 0.5 (standard S-curve), deeper levels tighten slightly
t = 0.5 if depth == 1 else 0.45
cpx = x1 + (x2 - x1) * t
return f"M{x1:.1f},{py:.1f} C{cpx:.1f},{py:.1f} {cpx:.1f},{cy:.1f} {x2:.1f},{cy:.1f}"
# ─────────────────────────────────────────────────────────────────────────────
# Static SVG (Python-side export)
# ─────────────────────────────────────────────────────────────────────────────
def _xml(s):
return str(s).replace("&","&").replace("<","<").replace(">",">").replace('"',""")
def render_svg_static(tree, positions, include_xml_header=True):
nodes = flatten_tree(tree)
minx, miny, maxx, maxy = bounding_box(positions, nodes)
W, H = maxx - minx, maxy - miny
cmap = build_color_map(tree)
L = []
if include_xml_header:
L.append('<?xml version="1.0" encoding="UTF-8"?>')
L.append(f'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="{minx:.1f} {miny:.1f} {W:.1f} {H:.1f}" '
f'width="{W:.0f}" height="{H:.0f}">')
# defs: glow + gradient for root
L.append('''<defs>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<linearGradient id="root-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4c5fdb"/>
<stop offset="100%" stop-color="#7c8cf8"/>
</linearGradient>
</defs>''')
# background
L.append(f'<rect x="{minx:.1f}" y="{miny:.1f}" width="{W:.1f}" height="{H:.1f}" fill="#0d0f1a"/>')
# ── edges ──
for node in nodes:
p = positions.get(node["_id"])
if not p or p["parent_id"] is None: continue
pp = positions.get(p["parent_id"])
if not pp: continue
pnode = next((n for n in nodes if n["_id"] == p["parent_id"]), None)
if not pnode: continue
color = cmap.get(node["_id"], "#888")
depth = node["_depth"]
sw = 2.5 if depth == 1 else (1.8 if depth == 2 else 1.3)
op = 0.85 if depth == 1 else (0.6 if depth == 2 else 0.4)
d_path = edge_path(pp["x"], pp["y"], pnode["_w"],
p["x"], p["y"], node["_w"], depth)
L.append(f'<path d="{d_path}" fill="none" stroke="{color}" '
f'stroke-width="{sw}" stroke-opacity="{op}" '
f'stroke-linecap="round" stroke-linejoin="round"/>')
# ── nodes ──
for node in nodes:
p = positions.get(node["_id"])
if not p: continue
depth = node["_depth"]
c = CFG[min(depth, len(CFG)-1)]
w, h = node["_w"], node["_h"]
nx, ny = p["x"] - w/2, p["y"] - h/2
color = cmap.get(node["_id"], "#888")
label = node.get("label") or node.get("central", "")
# rect
if depth == 0:
L.append(f'<rect x="{nx:.1f}" y="{ny:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'rx="{c["rx"]}" fill="url(#root-grad)" filter="url(#glow)"/>')
tc = "#ffffff"
elif depth == 1:
L.append(f'<rect x="{nx:.1f}" y="{ny:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'rx="{c["rx"]}" fill="{color}30" stroke="{color}" stroke-width="2"/>')
tc = "#ffffff"
elif depth == 2:
L.append(f'<rect x="{nx:.1f}" y="{ny:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'rx="{c["rx"]}" fill="{color}18" stroke="{color}bb" stroke-width="1.5"/>')
tc = "#e0e4f0"
else:
L.append(f'<rect x="{nx:.1f}" y="{ny:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'rx="{c["rx"]}" fill="{color}0e" stroke="{color}77" stroke-width="1"/>')
tc = "#a8b0c8"
# text
L.append(f'<text x="{p["x"]:.1f}" y="{p["y"]:.1f}" '
f'dominant-baseline="central" text-anchor="middle" '
f'font-family="{FONT_STACK}" font-size="{c["fs"]}" font-weight="{c["fw"]}" '
f'fill="{tc}">{_xml(label)}</text>')
L.append('</svg>')
return "\n".join(L)
# ─────────────────────────────────────────────────────────────────────────────
# XMind export
# ─────────────────────────────────────────────────────────────────────────────
def build_xmind(tree, title):
"""
Generate an .xmind file compatible with both XMind 8 and XMind 2020+.
Includes content.xml (XMind 8 format) AND content.json (XMind 2020 format).
"""
def uid(): return uuid.uuid4().hex[:26]
# ── content.json (XMind 2020+) ─────────────────────────────────────────
def xnode_json(node):
children = node.get("branches", []) + node.get("children", [])
obj = {"id": uid(), "class": "topic",
"title": node.get("label") or node.get("central", "")}
if children:
obj["children"] = {"attached": [xnode_json(c) for c in children]}
if node.get("color"):
obj["style"] = {"id": uid(), "properties": {
"line-color": node["color"],
"line-width": "2pt",
"background-color": node["color"] + "33",
"border-line-color": node["color"],
"shape-class": "org.xmind.topicShape.roundedRect",
}}
return obj
root_json = xnode_json(tree)
root_json["structureClass"] = "org.xmind.ui.map.unbalanced"
sheet_id = uid()
content_json = [{
"id": sheet_id, "class": "sheet", "title": title,
"rootTopic": root_json, "theme": {}, "extensions": [],
}]
# ── content.xml (XMind 8) ──────────────────────────────────────────────
def _xe(s):
return (str(s).replace("&", "&").replace("<", "<")
.replace(">", ">").replace('"', """))
def xnode_xml(node, depth=0):
children = node.get("branches", []) + node.get("children", [])
label = node.get("label") or node.get("central", "")
color = node.get("color", "")
ind = " " * depth
lines = [f'{ind}<topic id="{uid()}"']
if depth == 0:
lines[0] += ' structure-class="org.xmind.ui.map.unbalanced"'
if color:
style_id = uid()
lines[0] += f' style-id="{style_id}"'
lines[0] += ">"
lines.append(f'{ind} <title>{_xe(label)}</title>')
if color:
lines.append(f'{ind} <style-ref id="{style_id}"/>')
if children:
lines.append(f'{ind} <children>')
lines.append(f'{ind} <topics type="attached">')
for child in children:
lines.extend(xnode_xml(child, depth + 3).split("\n"))
lines.append(f'{ind} </topics>')
lines.append(f'{ind} </children>')
lines.append(f'{ind}</topic>')
return "\n".join(lines)
def build_styles(node, styles=None):
if styles is None: styles = []
color = node.get("color", "")
if color:
styles.append(
f' <style id="{uid()}" type="topic">\n'
f' <topic-properties border-line-color="{color}" '
f'fill-color="{color}33" line-color="{color}" line-width="2pt"/>\n'
f' </style>'
)
for c in node.get("branches", []) + node.get("children", []):
build_styles(c, styles)
return styles
xml_sheet_id = uid()
xml_root = xnode_xml(tree, 0)
styles = build_styles(tree)
content_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<xmap-content xmlns="urn:xmind:xmap:xmlns:content:2.0"
xmlns:fo="http://www.w3.org/1999/XSL/Format"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="2.0">
<sheet id="{xml_sheet_id}">
{xml_root}
<title>{_xe(title)}</title>
</sheet>
</xmap-content>"""
styles_xml = ('<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n'
'<xmap-styles xmlns="urn:xmind:xmap:xmlns:style:2.0" version="2.0">\n'
+ "\n".join(styles) + "\n</xmap-styles>")
# ── metadata ───────────────────────────────────────────────────────────
metadata = {
"modifier": "",
"created": datetime.now().strftime("%Y-%m-%dT%H:%M:%S.000+0000"),
"creator": {"name": "OpenClaw MindMap", "version": "5.0", "platform": ""},
}
manifest = {"file-entries": {
"content.json": {"media-type": "application/json"},
"content.xml": {"media-type": "text/xml"},
"styles.xml": {"media-type": "text/xml"},
"metadata.json": {"media-type": "application/json"},
"manifest.json": {"media-type": "application/json"},
}}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False))
zf.writestr("content.json", json.dumps(content_json, ensure_ascii=False, indent=2))
zf.writestr("content.xml", content_xml.encode("utf-8"))
zf.writestr("styles.xml", styles_xml.encode("utf-8"))
zf.writestr("metadata.json", json.dumps(metadata, ensure_ascii=False, indent=2))
return buf.getvalue()
# ─────────────────────────────────────────────────────────────────────────────
# HTML template (raw string — no f-string, JS braces are literal)
# ─────────────────────────────────────────────────────────────────────────────
_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>__TITLE__</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",
"Hiragino Sans GB","Microsoft YaHei",sans-serif;
background:#0d0f1a;color:#e8eaf0;
height:100vh;overflow:hidden;display:flex;flex-direction:column;
}
header{
padding:8px 16px;background:rgba(255,255,255,.04);
border-bottom:1px solid rgba(255,255,255,.07);
display:flex;flex-direction:column;gap:6px;
flex-shrink:0;user-select:none;
}
.header-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap;}
.header-row.top{justify-content:space-between;}
header h1{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;flex:1;min-width:0;}
.btn-group{
display:flex;align-items:center;gap:2px;
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
border-radius:8px;padding:2px;
}
.btn-group-label{
font-size:10px;color:rgba(255,255,255,.28);padding:0 5px 0 7px;
white-space:nowrap;letter-spacing:.04em;text-transform:uppercase;
}
.btn{
background:transparent;border:1px solid transparent;
color:#c0c4dc;padding:4px 9px;border-radius:6px;font-size:12px;
cursor:pointer;transition:background .12s,color .12s,border-color .12s;
white-space:nowrap;
}
.btn:hover{background:rgba(255,255,255,.1);color:#fff;border-color:rgba(255,255,255,.13);}
.btn:active{background:rgba(255,255,255,.16);}
.btn.exp{
font-size:11px;padding:4px 10px;
color:rgba(180,200,255,.75);
background:rgba(55,85,200,.14);
border-color:rgba(90,130,255,.22);
}
.btn.exp:hover{background:rgba(75,110,230,.3);border-color:rgba(120,160,255,.4);color:#ccd8ff;}
.sep{width:1px;height:16px;background:rgba(255,255,255,.1);margin:0 2px;}
.btn.layout-btn{padding:3px 8px;font-size:11px;color:rgba(255,255,255,.5);}
.btn.layout-btn:hover{color:#e8eaf0;}
.btn.layout-btn.active{background:rgba(124,140,248,.28);border-color:rgba(124,140,248,.55);color:#b4bcff;font-weight:500;}
.btn.undo-btn{font-size:14px;padding:3px 7px;color:rgba(255,255,255,.38);}
.btn.undo-btn:not([disabled]):hover{color:#e8eaf0;}
.btn.undo-btn[disabled]{opacity:.28;cursor:default;}
.btn.undo-btn[disabled]:hover{background:transparent;border-color:transparent;}
.btn.undo-btn{padding:4px 8px;font-size:12px;}
.btn.undo-btn:disabled{opacity:.3;cursor:default;}
.meta{font-size:10px;color:rgba(255,255,255,.22);white-space:nowrap;margin-left:auto;}
#wrap{flex:1;overflow:hidden;position:relative;}
svg{width:100%;height:100%;display:block;}
.nd{cursor:pointer;}
.nd .bg{transition:filter .12s;}
.nd:hover .bg{filter:brightness(1.3);}
.nd.selected .sel-ring{display:block;}
.sel-ring{display:none;pointer-events:none;}
.rh {opacity:0;transition:opacity .15s;cursor:ew-resize;}
.rh-b{opacity:0;transition:opacity .15s;cursor:ns-resize;}
.nd:hover .rh,.nd:hover .rh-b{opacity:1;}
.tog circle{transition:fill .12s;}
.tog:hover circle{opacity:.9;}
.edge{fill:none;}
/* context menu */
#ctx-menu{
position:fixed;display:none;z-index:300;
background:rgba(18,22,38,.98);border:1px solid rgba(255,255,255,.13);
border-radius:9px;padding:5px 0;min-width:165px;
box-shadow:0 8px 32px rgba(0,0,0,.5);font-size:13px;
}
#ctx-menu.open{display:block;}
.ctx-item{padding:7px 16px;cursor:pointer;display:flex;align-items:center;gap:9px;color:#e0e4f0;transition:background .1s;user-select:none;}
.ctx-item:hover{background:rgba(255,255,255,.08);}
.ctx-item.danger{color:#f87171;}
.ctx-item.danger:hover{background:rgba(248,113,113,.1);}
.ctx-sep{height:1px;background:rgba(255,255,255,.08);margin:4px 0;}
.ctx-icon{width:16px;text-align:center;font-size:14px;}
.ctx-colors{padding:6px 12px;display:flex;gap:6px;flex-wrap:wrap;}
.color-dot{width:18px;height:18px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:transform .1s,border-color .1s;}
.color-dot.active{border-color:#fff;transform:scale(1.25);box-shadow:0 0 0 2px rgba(255,255,255,.3);}
.color-dot:hover{transform:scale(1.2);border-color:rgba(255,255,255,.5);}
/* toast */
#toast{
position:fixed;bottom:20px;left:50%;transform:translateX(-50%);
background:rgba(13,15,26,.97);border:1px solid rgba(255,255,255,.15);
border-radius:8px;padding:7px 18px;font-size:13px;
pointer-events:none;opacity:0;transition:opacity .2s;z-index:99;
}
#toast.show{opacity:1;}
</style>
</head>
<body>
<header>
<!-- 第一行:标题 + 导出 -->
<div class="header-row top">
<h1>🧠 __TITLE__</h1>
<div class="btn-group">
<span class="btn-group-label">导出</span>
<button class="btn exp" onclick="exportAs('svg')" title="导出 SVG">SVG</button>
<button class="btn exp" onclick="exportAs('png')" title="导出 PNG">PNG</button>
<button class="btn exp" onclick="exportAs('jpg')" title="导出 JPG">JPG</button>
<button class="btn exp" onclick="exportAs('pdf')" title="导出 PDF">PDF</button>
<button class="btn exp" onclick="exportXmind()" title="导出 XMind">XMind</button>
</div>
</div>
<!-- 第二行:视图控制 + 撤销 + 布局 -->
<div class="header-row">
<div class="btn-group">
<span class="btn-group-label">视图</span>
<button class="btn" onclick="resetView()" title="重置视图">⊙</button>
<button class="btn" onclick="expandAll()" title="全部展开">⊞</button>
<button class="btn" onclick="collapseAll()" title="全部折叠">⊟</button>
<button class="btn" onclick="zoomIn()" title="放大">+</button>
<button class="btn" onclick="zoomOut()" title="缩小">-</button>
</div>
<div class="btn-group">
<span class="btn-group-label">历史</span>
<button class="btn undo-btn" id="undo-btn" onclick="undo()" title="撤销 (Ctrl+Z)" disabled>↶</button>
<button class="btn undo-btn" id="redo-btn" onclick="redo()" title="重做 (Ctrl+Y)" disabled>↷</button>
</div>
<div class="btn-group">
<span class="btn-group-label">布局</span>
<button class="btn layout-btn active" id="layout-btn-0" onclick="switchLayout(0)" title="左右均衡">⇆ 左右</button>
<button class="btn layout-btn" id="layout-btn-1" onclick="switchLayout(1)" title="全向辐射">✶ 辐射</button>
<button class="btn layout-btn" id="layout-btn-2" onclick="switchLayout(2)" title="向右树形">➡ 树形</button>
<button class="btn layout-btn" id="layout-btn-3" onclick="switchLayout(3)" title="垂直向下">🌳 垂直</button>
<button class="btn layout-btn" id="layout-btn-4" onclick="switchLayout(4)" title="力导向动画">⚡ 力导向</button>
<button class="btn layout-btn" id="layout-btn-5" onclick="switchLayout(5)" title="时间线">⏩ 时间线</button>
<button class="btn layout-btn" id="layout-btn-6" onclick="switchLayout(6)" title="鱼骨图">🐟 鱼骨</button>
<button class="btn layout-btn" id="layout-btn-7" onclick="switchLayout(7)" title="括弧图">} 括弧</button>
</div>
<span class="meta">右键菜单 · Tab 添加子节点 · Del 删除 · Ctrl+Z 撤销</span>
</div>
</header>
<div id="wrap">
<svg id="svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<linearGradient id="root-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4c5fdb"/>
<stop offset="100%" stop-color="#7c8cf8"/>
</linearGradient>
</defs>
<g id="edges-g"></g>
<g id="nodes-g"></g>
</svg>
</div>
<div id="ctx-menu">
<div class="ctx-item" onclick="ctxAction('add-child')"><span class="ctx-icon">+</span>添加子节点</div>
<div class="ctx-item" onclick="ctxAction('add-sibling')"><span class="ctx-icon">↵</span>添加兄弟节点</div>
<div class="ctx-sep"></div>
<div class="ctx-item" style="font-size:11px;color:rgba(255,255,255,.4);padding:4px 16px;cursor:default;" id="ctx-color-label">更改节点颜色</div>
<div class="ctx-colors" id="ctx-colors"></div>
<div class="ctx-sep"></div>
<div class="ctx-item danger" onclick="ctxAction('delete')"><span class="ctx-icon">🗑</span>删除节点</div>
</div>
<div id="toast"></div>
<script>
const RAW = __RAW_JSON__;
const TITLE = __TITLE_JSON__;
const SVG_NS = "http://www.w3.org/2000/svg";
const CFG = [
{h:48,fs:16,fw:"700",rx:12,px:32,minW:180},
{h:38,fs:13,fw:"600",rx: 8,px:24,minW:110},
{h:30,fs:12,fw:"400",rx: 6,px:18,minW: 80},
{h:26,fs:11,fw:"400",rx: 5,px:16,minW: 72},
];
const PALETTE=["#4A90D9","#E86C3A","#27AE60","#9B59B6","#E74C3C","#F39C12","#1ABC9C","#E91E63","#00BCD4","#8BC34A"];
const H_GAP=[0,64,48,40], V_GAP=[0,20,12,8];
const MIN_W=60, MIN_H=20, HW=7;
let nodeMap={}, _nid=0, selectedId=null, ctxTargetId=null;
let _pushScheduled=false; // rAF throttle for pushAway
let _undoStack=[], _redoStack=[]; // undo/redo snapshot stacks
const MAX_UNDO=50;
function measureW(text,depth){
const c=CFG[Math.min(depth,CFG.length-1)];
let w=0; for(const ch of String(text)) w+=ch.charCodeAt(0)>127?c.fs*0.92:c.fs*0.58;
return Math.max(c.minW,w+c.px*2);
}
function annotate(node, depth, branchColor){
node._id=node._id||"n"+(++_nid); node._depth=depth;
if(node._collapsed===undefined) node._collapsed=false;
node._pinned=node._pinned||false;
if(node._px===undefined) node._px=null;
if(node._py===undefined) node._py=null;
node._w=node._w||measureW(node.label||node.central||"",depth);
node._h=node._h||CFG[Math.min(depth,CFG.length-1)].h;
// 缓存所属分支的主题色,O(1) 查色,避免 nodeColor 每帧递归
if(depth===0) node._branchColor=null;
else if(depth===1) node._branchColor=node.color||null;
else node._branchColor=branchColor||null;
nodeMap[node._id]=node;
const bc = depth===1 ? (node.color||null) : branchColor||null;
(node.children||[]).forEach(ch=>annotate(ch,depth+1,bc));
(node.branches||[]).forEach(b=>annotate(b,1,null));
}
function visKids(node){return node._collapsed?[]:(node.children||[]);}
function newId(){_nid++;let id="n"+_nid;while(nodeMap[id]){_nid++;id="n"+_nid;}return id;}
let pos={};
let currentLayout=0; // 0=左右均衡 1=辐射 2=向右树 3=垂直树 4=力导向
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 0 — 左右均衡树(默认)
分支均分左右,每侧垂直树形,S曲线+直角折线
══════════════════════════════════════════════════════════════════════════ */
function subtreeH(node){
const vg=V_GAP[Math.min(node._depth,V_GAP.length-1)];
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h,kids.reduce((s,k)=>s+subtreeH(k),0)+vg*(kids.length-1));
}
function layoutSubtree(node,cx,cy,side){
pos[node._id]={x:cx,y:cy,parentId:pos[node._id]?.parentId,side};
const kids=visKids(node); if(!kids.length) return;
const vg=V_GAP[Math.min(node._depth+1,V_GAP.length-1)];
const hg=H_GAP[Math.min(node._depth+1,H_GAP.length-1)];
const maxCW=Math.max(...kids.map(k=>k._w));
const childCX=cx+side*(node._w/2+hg+maxCW/2);
const heights=kids.map(k=>subtreeH(k));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(kids.length-1);
let curY=cy-totalH/2;
kids.forEach((kid,i)=>{
const kcy=curY+heights[i]/2;
pos[kid._id]={x:childCX,y:kcy,parentId:node._id,side};
layoutSubtree(kid,childCX,kcy,side);
curY+=heights[i]+vg;
});
}
function layout0(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:0};
const branches=tree.branches||[]; if(!branches.length) return;
const nRight=Math.ceil(branches.length/2);
function placeSide(brs,side){
if(!brs.length) return;
const maxBW=Math.max(...brs.map(b=>b._w));
const branchCX=side*(tree._w/2+H_GAP[1]+maxBW/2);
const heights=brs.map(b=>subtreeH(b));
const totalH=heights.reduce((a,b)=>a+b,0)+V_GAP[1]*(brs.length-1);
let curY=-totalH/2;
brs.forEach((b,i)=>{
const bcy=curY+heights[i]/2;
pos[b._id]={x:branchCX,y:bcy,parentId:tree._id,side:side};
layoutSubtree(b,branchCX,bcy,side);
curY+=heights[i]+V_GAP[1];
});
}
placeSide(branches.slice(0,nRight),1);
placeSide(branches.slice(nRight),-1);
}
function edgePath0(px,py,pw,cx,cy,cw,depth,side){
/* 左右均衡布局:全程三次贝塞尔,从节点侧边水平切出/切入
控制点在水平中点,产生优雅的 S 形曲线 */
const dx = cx - px;
const s = dx >= 0 ? 1 : -1; // 实际方向
const x1 = px + s * pw/2; // 父节点出口(侧边中心)
const x2 = cx - s * cw/2; // 子节点入口(侧边中心)
// 控制点张力:depth=1 用 0.5(标准 S 曲线),深层略收紧
const t = depth === 1 ? 0.5 : 0.45;
const cpx = x1 + (x2 - x1) * t;
// 节点几乎水平对齐时退化为直线
if(Math.abs(cy - py) < 3) return `Mx1,py Lx2,cy`;
return `Mx1,py Ccpx,py cpx,cy x2,cy`;
}
function edgePath1(px,py,cx,cy,depth){
/* 辐射布局:从父节点中心到子节点中心,沿径向方向平滑贝塞尔
控制点在各自 y 保持,让线条沿水平/垂直方向自然流出 */
const mx = (px+cx)/2, my = (py+cy)/2;
const t = depth === 1 ? 0.5 : 0.42;
const cp1x = px+(cx-px)*t, cp2x = cx-(cx-px)*t;
return `Mpx,py Ccp1x,py cp2x,cy cx,cy`;
}
function edgePath2(px,py,pw,cx,cy,cw,depth){
/* 树形(向右):从父节点右边出发,到子节点左边进入,圆角肘形
保留视觉上的流程感,同时用贝塞尔圆滑转角 */
const x1 = px + pw/2; // 父右边
const x2 = cx - cw/2; // 子左边
const mid = x1 + (x2 - x1) * 0.5;
if(Math.abs(cy - py) < 3) return `Mx1,py Lx2,cy`;
// 三次贝塞尔:水平出 → 水平入,中点弯曲
return `Mx1,py Cmid,py mid,cy x2,cy`;
}
function edgePath3(px,py,pw,cx,cy,cw,depth){
/* 垂直树:中心到中心,控制点保持各自 x,产生垂直 S 曲线 */
const my = (py + cy) / 2;
if(Math.abs(cx - px) < 3) return `Mpx,py Lcx,cy`;
return `Mpx,py Cpx,my cx,my cx,cy`;
}
function edgePath4(px,py,cx,cy){
/* 力导向:点到点平滑贝塞尔,控制点在中点 */
const mx = (px+cx)/2, my = (py+cy)/2;
const dx = cx-px, dy = cy-py, len = Math.sqrt(dx*dx+dy*dy)||1;
const perp = Math.min(len*0.12, 24);
const nx = -dy/len*perp, ny = dx/len*perp;
return `Mpx,py Qmx+nx,my+ny cx,cy`;
}
function drawSpine3(){
// 垂直树不需要装饰线,清除旧环形圈
document.querySelectorAll(".circ-ring").forEach(e => e.remove());
document.getElementById("fishbone-spine")?.remove();
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 1 — 全辐射(圆形散射)
══════════════════════════════════════════════════════════════════════════ */
function layout1(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:1};
const branches=tree.branches||[]; if(!branches.length) return;
function leafCount(node){
const kids=visKids(node);
return kids.length?kids.reduce((s,k)=>s+leafCount(k),0):1;
}
const totalLeaves=branches.reduce((s,b)=>s+leafCount(b),0);
const R1=Math.max(180, tree._w/2+120);
const R2=120;
let angle=-Math.PI/2;
branches.forEach(branch=>{
const frac=leafCount(branch)/totalLeaves;
const span=frac*Math.PI*2;
const mid=angle+span/2;
angle+=span;
const side=Math.cos(mid)>=0?1:-1;
const bx=Math.cos(mid)*R1, by=Math.sin(mid)*R1;
pos[branch._id]={x:bx,y:by,parentId:tree._id,side,angle:mid};
const kids=visKids(branch);
if(!kids.length) return;
const fanSpan=Math.min(span*.8, Math.PI*.6);
const fanStart=mid-fanSpan/2;
kids.forEach((kid,i)=>{
const ka=fanStart+(fanSpan*i)/(Math.max(kids.length-1,1))||mid;
const kR=R1+R2+kid._w/2;
const kx=Math.cos(ka)*kR, ky=Math.sin(ka)*kR;
pos[kid._id]={x:kx,y:ky,parentId:branch._id,side:Math.cos(ka)>=0?1:-1,angle:ka};
const gkids=visKids(kid);
if(!gkids.length) return;
const gFan=Math.min(fanSpan/(kids.length||1)*.9, Math.PI*.3);
gkids.forEach((gk,j)=>{
const ga=ka+(j-(gkids.length-1)/2)*gFan/(Math.max(gkids.length-1,1)||1);
const gR=kR+R2*.7+gk._w/2;
pos[gk._id]={x:Math.cos(ga)*gR,y:Math.sin(ga)*gR,parentId:kid._id,side:Math.cos(ga)>=0?1:-1,angle:ga};
});
});
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 2 — 向右树(Org Chart)
══════════════════════════════════════════════════════════════════════════ */
function subtreeH2(node){
const vg=Math.max(V_GAP[Math.min(node._depth,V_GAP.length-1)],14);
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h,kids.reduce((s,k)=>s+subtreeH2(k),0)+vg*(kids.length-1));
}
function layoutSubtree2(node,lx,cy){
const kids=visKids(node); if(!kids.length) return;
const hg=H_GAP[Math.min(node._depth+1,H_GAP.length-1)]+8;
const vg=Math.max(V_GAP[Math.min(node._depth+1,V_GAP.length-1)],14);
const maxCW=Math.max(...kids.map(k=>k._w));
const childLX=lx+node._w+hg;
const childCX=childLX+maxCW/2;
const heights=kids.map(k=>subtreeH2(k));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(kids.length-1);
let curY=cy-totalH/2;
kids.forEach((kid,i)=>{
const kcy=curY+heights[i]/2;
pos[kid._id]={x:childCX,y:kcy,parentId:node._id,side:1};
layoutSubtree2(kid,childLX,kcy);
curY+=heights[i]+vg;
});
}
function layout2(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:1};
const branches=tree.branches||[]; if(!branches.length) return;
const hg=H_GAP[1]+8;
const vg=Math.max(V_GAP[1],14);
const maxBW=Math.max(...branches.map(b=>b._w));
const branchLX=tree._w/2+hg;
const branchCX=branchLX+maxBW/2;
const heights=branches.map(b=>subtreeH2(b));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(branches.length-1);
let curY=-totalH/2;
branches.forEach((b,i)=>{
const bcy=curY+heights[i]/2;
pos[b._id]={x:branchCX,y:bcy,parentId:tree._id,side:1};
layoutSubtree2(b,branchLX,bcy);
curY+=heights[i]+vg;
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 3 — 垂直树(Vertical Tree / Top-Down)
══════════════════════════════════════════════════════════════════════════ */
function subtreeW3(node){
const hg=20;
const kids=visKids(node);
if(!kids.length) return node._w;
const childrenW=kids.reduce((s,k)=>s+subtreeW3(k),0)+hg*(kids.length-1);
return Math.max(node._w,childrenW);
}
function placeSubtree3(node,cx,top,parentId){
const cy=top+node._h/2;
pos[node._id]={x:cx,y:cy,parentId,side:1};
const kids=visKids(node); if(!kids.length) return;
const V_STEP=80, H_GAP_3=20;
const childTop=top+node._h+V_STEP;
const widths=kids.map(k=>subtreeW3(k));
const totalW=widths.reduce((a,b)=>a+b,0)+H_GAP_3*(kids.length-1);
let curX=cx-totalW/2;
kids.forEach((kid,i)=>{
const kidCX=curX+widths[i]/2;
placeSubtree3(kid,kidCX,childTop,node._id);
curX+=widths[i]+H_GAP_3;
});
}
function layout3(tree){
pos={};
const branches=tree.branches||[];
pos[tree._id]={x:0,y:0,parentId:null,side:1};
if(!branches.length) return;
const V_STEP=80, H_GAP_3=20;
const top1=tree._h/2+V_STEP;
const widths=branches.map(b=>subtreeW3(b));
const totalW=widths.reduce((a,b)=>a+b,0)+H_GAP_3*(branches.length-1);
let curX=-totalW/2;
branches.forEach((branch,i)=>{
const bx=curX+widths[i]/2;
placeSubtree3(branch,bx,top1,tree._id);
curX+=widths[i]+H_GAP_3;
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 4 — 力导向(Force-Directed)
· Coulomb 斥力 + Hooke 弹簧 + Verlet 积分
· 根节点固定在中心,其余节点自由浮动
· 120 帧动画后定格
══════════════════════════════════════════════════════════════════════════ */
let _fdRunning = false;
let _fdTimer = null;
function layout4(tree){
stopFD();
// ── 1. 收集所有节点(全树,不管 collapsed)────────────────────────────
const all = [];
;(function walk(node){
all.push(node);
(node._depth===0 ? (node.branches||[]) : visKids(node)).forEach(walk);
})(tree);
// ── 2. 从树结构建边(不依赖 pos,避免 stale 问题)─────────────────────
const edges = [];
;(function walkE(node){
const kids = node._depth===0 ? (node.branches||[]) : visKids(node);
kids.forEach(kid=>{ edges.push([node._id, kid._id]); walkE(kid); });
})(tree);
// ── 3. 先用 layout0 给一个合理初始骨架,再叠加力导向 ─────────────────
layout0(tree);
// ── 4. 给 pos 里没有的节点(collapsed)补一个随机初始位置 ───────────────
const seed = () => (Math.random()-0.5)*80;
all.forEach(node=>{
if(!pos[node._id]){
// 找父节点位置作为起点
const parentId = (node._depth===0) ? null
: edges.find(([a,b])=>b===node._id)?.[0] ?? null;
const pp = parentId ? pos[parentId] : null;
pos[node._id] = {
x: (pp ? pp.x : 0) + seed(),
y: (pp ? pp.y : 0) + seed(),
parentId, side:1, vx:0, vy:0
};
} else {
pos[node._id].vx = 0;
pos[node._id].vy = 0;
}
});
// ── 5. 力导向迭代 ────────────────────────────────────────────────────
const K_REPEL = 30000;
const K_SPRING = 0.09;
const DAMPING = 0.80;
const MAX_V = 55;
const FRAMES = 130;
function idealLen(depthA, depthB){ return 150 + Math.max(depthA,depthB)*35; }
let frame = 0;
function tick(){
if(!_fdRunning || currentLayout!==4){ _fdRunning=false; return; }
frame++;
const cool = Math.max(0.04, 1 - frame/FRAMES);
const fx={}, fy={};
all.forEach(n=>{ fx[n._id]=0; fy[n._id]=0; });
// Coulomb 斥力(所有节点对)
for(let i=0;i<all.length;i++){
const a=all[i], pa=pos[a._id];
if(!pa) continue;
for(let j=i+1;j<all.length;j++){
const b=all[j], pb=pos[b._id];
if(!pb) continue;
let dx=pb.x-pa.x, dy=pb.y-pa.y;
const dist2=dx*dx+dy*dy||0.01;
const dist=Math.sqrt(dist2);
// 额外排斥:节点尺寸内强推
const minD=(a._w+b._w)*0.5+24;
const f=K_REPEL/dist2*cool;
const push=dist<minD?(minD-dist)*1.2:0;
const ux=dx/dist, uy=dy/dist;
fx[a._id]-=(f+push)*ux; fy[a._id]-=(f+push)*uy;
fx[b._id]+=(f+push)*ux; fy[b._id]+=(f+push)*uy;
}
}
// Hooke 弹簧引力(有边的节点对)
edges.forEach(([aid,bid])=>{
const pa=pos[aid], pb=pos[bid];
if(!pa||!pb) return;
const dx=pb.x-pa.x, dy=pb.y-pa.y;
const dist=Math.sqrt(dx*dx+dy*dy)||0.01;
const na=nodeMap[aid]||{_depth:0}, nb=nodeMap[bid]||{_depth:1};
const target=idealLen(na._depth, nb._depth);
const stretch=(dist-target)*K_SPRING*cool;
const ux=dx/dist, uy=dy/dist;
if(aid!==tree._id){ fx[aid]+=stretch*ux; fy[aid]+=stretch*uy; }
fx[bid]-=stretch*ux; fy[bid]-=stretch*uy;
});
// 弱中心引力(防止整体漂移)
all.forEach(node=>{
if(node._id===tree._id) return;
const p=pos[node._id]; if(!p) return;
fx[node._id]-=p.x*0.006*cool;
fy[node._id]-=p.y*0.006*cool;
});
// 更新速度和位置
all.forEach(node=>{
if(node._id===tree._id) return;
const p=pos[node._id]; if(!p) return;
p.vx=(p.vx+fx[node._id])*DAMPING;
p.vy=(p.vy+fy[node._id])*DAMPING;
const spd=Math.sqrt(p.vx*p.vx+p.vy*p.vy)||1;
if(spd>MAX_V){ p.vx=p.vx/spd*MAX_V; p.vy=p.vy/spd*MAX_V; }
p.x+=p.vx; p.y+=p.vy;
p.side=p.x>=0?1:-1;
});
renderAll(tree);
if(frame<FRAMES){
_fdTimer=requestAnimationFrame(tick);
} else {
_fdRunning=false;
}
}
_fdRunning=true;
frame=0;
_fdTimer=requestAnimationFrame(tick);
}
function stopFD(){
_fdRunning = false;
if(_fdTimer){ cancelAnimationFrame(_fdTimer); _fdTimer=null; }
}
function edgePath4(px,py,cx,cy){
// 力导向用平滑曲线
const mx=(px+cx)/2, my=(py+cy)/2;
const dx=cx-px, dy=cy-py, len=Math.sqrt(dx*dx+dy*dy)||1;
// 控制点:垂直于连线方向偏移,形成弧线
const perp = Math.min(len*0.15, 30);
const nx=-dy/len*perp, ny=dx/len*perp;
return `Mpx,py Qmx+nx,my+ny cx,cy`;
}
function drawSpine3(){
document.querySelectorAll(".circ-ring,.fd-ring").forEach(e=>e.remove());
document.getElementById("fishbone-spine")?.remove();
document.getElementById("timeline-axis")?.remove();
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 5 — 时间线(Timeline / 水平流程)
· 中心节点在最左侧
· 主分支从左到右等间距水平排列
· 子节点垂直向下展开
· 主分支之间有水平时间轴主干线
══════════════════════════════════════════════════════════════════════════ */
function subtreeH5(node){
const vg=12;
const kids=visKids(node); if(!kids.length) return node._h;
return node._h + 60 + kids.reduce((s,k)=>s+k._h+vg,0) - vg;
}
function layout5(tree){
pos={};
const branches=tree.branches||[];
// root at far left
pos[tree._id]={x:0, y:0, parentId:null, side:1};
if(!branches.length) return;
const H_STEP = 220; // horizontal distance between branch columns
const V_TOP = 90; // vertical distance from timeline axis to first child
// Place branches horizontally
branches.forEach((b,i)=>{
const bx = tree._w/2 + H_STEP * (i+1);
pos[b._id]={x:bx, y:0, parentId:tree._id, side:1};
// Children stacked vertically below
const kids=visKids(b); if(!kids.length) return;
let curY = V_TOP;
kids.forEach(kid=>{
pos[kid._id]={x:bx, y:curY, parentId:b._id, side:1};
// Grandchildren further right
const gkids=visKids(kid); if(!gkids.length){ curY+=kid._h+12; return; }
let gy=curY;
gkids.forEach(gk=>{
pos[gk._id]={x:bx+kid._w/2+80+gk._w/2, y:gy, parentId:kid._id, side:1};
gy+=gk._h+8;
});
curY=Math.max(curY+kid._h+12, gy);
});
});
}
function edgePath5(px,py,pw,cx,cy,cw,depth){
if(depth===1){
// Timeline axis: horizontal straight line
const x1=px+pw/2, x2=cx-cw/2;
return `Mx1,py Lx2,cy`;
}
// Branch to children: vertical drop then horizontal
if(Math.abs(cx-px)<3){
// Straight down
const y1=py+20, y2=cy-cw/4;
return `Mpx,y1 Lcx,y2`;
}
// Horizontal bezier for grandchildren
const x1=px+pw/2, x2=cx-cw/2;
const mid=(x1+x2)/2;
if(Math.abs(cy-py)<3) return `Mx1,py Lx2,cy`;
return `Mx1,py Cmid,py mid,cy x2,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 6 — 鱼骨图(Fishbone / Ishikawa)
· 中心节点(鱼头)在右侧
· 水平主干(鱼脊)从右向左延伸
· 主分支交替从上下两侧 45° 斜向伸出(鱼骨)
· 子节点沿鱼骨方向排列
══════════════════════════════════════════════════════════════════════════ */
function layout6(tree){
pos={};
const branches=tree.branches||[];
// Fish head (root) on the right
pos[tree._id]={x:0, y:0, parentId:null, side:1};
if(!branches.length) return;
const SPINE_STEP = 180; // distance between bones along spine
const BONE_LEN = 130; // length of each bone (diagonal)
const SUB_GAP = 36; // gap between sub-nodes along bone
const ANGLE = Math.PI * 0.38; // ~68° from horizontal
branches.forEach((b,i)=>{
const spineX = -(tree._w/2 + 80 + SPINE_STEP * i);
const upDown = (i % 2 === 0) ? -1 : 1; // alternate up/down
const bx = spineX - Math.cos(ANGLE) * BONE_LEN;
const by = upDown * Math.sin(ANGLE) * BONE_LEN;
pos[b._id]={x:bx, y:by, parentId:tree._id, side:-1, _spineX:spineX};
// Children along the bone direction
const kids=visKids(b); if(!kids.length) return;
const dx = Math.cos(ANGLE) * SUB_GAP * upDown * 0;
const dirX = -Math.cos(ANGLE);
const dirY = upDown * Math.sin(ANGLE);
kids.forEach((kid,j)=>{
const dist = SUB_GAP * (j+1) + kid._w/2;
const kx = bx + dirX * dist * 0.3;
const ky = by + dirY * dist;
pos[kid._id]={x:kx, y:ky, parentId:b._id, side:-1};
// Grandchildren
const gkids=visKids(kid); if(!gkids.length) return;
gkids.forEach((gk,gi)=>{
pos[gk._id]={
x: kx - gk._w/2 - kid._w/2 - 30,
y: ky + (gi - (gkids.length-1)/2) * (gk._h + 6),
parentId:kid._id, side:-1
};
});
});
});
}
function edgePath6(px,py,pw,cx,cy,cw,depth,node){
if(depth===1){
// Bone: from spine attachment point to branch node
const spineX = pos[node?._id]?._spineX;
if(spineX !== undefined){
// Draw: spine point → branch node
return `MspineX,py Lcx,cy`;
}
return `Mpx,py Lcx,cy`;
}
// Sub-bones: straight lines
return `Mpx,py Lcx,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 7 — 括弧图(Brace Map / 层级分解)
· 中心节点在最左侧
· 父节点与子节点之间绘制 SVG 大括号 "}"
· 大括号的尖端对准父节点右侧,两端包裹所有子节点
· 强调 整体 → { 部分1, 部分2, ... } 的分解关系
══════════════════════════════════════════════════════════════════════════ */
function subtreeH7(node){
const vg=16;
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h, kids.reduce((s,k)=>s+subtreeH7(k),0)+vg*(kids.length-1));
}
function layout7(tree){
pos={};
pos[tree._id]={x:0, y:0, parentId:null, side:1};
const branches=tree.branches||[]; if(!branches.length) return;
const BRACE_W = 40; // width of the brace symbol area
const H_GAP_7 = 36; // gap between parent right edge and brace
const H_GAP_C = 20; // gap between brace and children left edge
function placeChildren(node, nodeRightX, cy){
const kids=visKids(node); if(!kids.length) return;
const vg=16;
const maxCW = Math.max(...kids.map(k=>k._w));
const childLX = nodeRightX + H_GAP_7 + BRACE_W + H_GAP_C;
const childCX = childLX + maxCW/2;
const heights = kids.map(k=>subtreeH7(k));
const totalH = heights.reduce((a,b)=>a+b,0) + vg*(kids.length-1);
let curY = cy - totalH/2;
kids.forEach((kid,i)=>{
const kcy = curY + heights[i]/2;
pos[kid._id]={x:childCX, y:kcy, parentId:node._id, side:1};
placeChildren(kid, childLX + maxCW/2, kcy);
curY += heights[i] + vg;
});
}
const maxBW = Math.max(...branches.map(b=>b._w));
const branchLX = tree._w/2 + H_GAP_7 + BRACE_W + H_GAP_C;
const branchCX = branchLX + maxBW/2;
const heights = branches.map(b=>subtreeH7(b));
const totalH = heights.reduce((a,b)=>a+b,0) + 16*(branches.length-1);
let curY = -totalH/2;
branches.forEach((b,i)=>{
const bcy = curY + heights[i]/2;
pos[b._id]={x:branchCX, y:bcy, parentId:tree._id, side:1};
placeChildren(b, branchLX + maxBW/2, bcy);
curY += heights[i] + 16;
});
}
function edgePath7(px,py,pw,cx,cy,cw,depth){
/* Brace Map edge: smooth cubic Bezier with a visible "step" shape.
Unlike tree layout's S-curve (which goes directly from parent to child),
the brace path goes: parent → horizontal exit → step down/up → horizontal enter → child
This creates the visual "}" bracket grouping effect.
Uses only C (cubic bezier) commands — no Q or L — for clean anti-aliased rendering.
*/
const x1 = px + pw/2; // parent right edge
const x2 = cx - cw/2; // child left edge
const midX = x1 + (x2 - x1) * 0.42; // vertical transit x
// Same height → simple S-curve
if(Math.abs(cy - py) < 4){
const cp = x1 + (x2 - x1) * 0.5;
return `Mx1,py Ccp,py cp,cy x2,cy`;
}
// Two-segment cubic bezier: parent→midpoint, midpoint→child
// Segment 1: horizontal exit from parent, curve down/up to midX
// Segment 2: from midX, curve horizontally into child
return `Mx1,py CmidX,py midX,py midX,(py+cy)/2 `
+ `CmidX,cy midX,cy x2,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT DISPATCHER
══════════════════════════════════════════════════════════════════════════ */
function layout(tree){
if(currentLayout===4){ layout4(tree); return; }
stopFD();
if(currentLayout===0) layout0(tree);
else if(currentLayout===1) layout1(tree);
else if(currentLayout===2) layout2(tree);
else if(currentLayout===3) layout3(tree);
else if(currentLayout===5) layout5(tree);
else if(currentLayout===6) layout6(tree);
else if(currentLayout===7) layout7(tree);
}
function edgePath(px,py,pw,cx,cy,cw,depth,side){
if(currentLayout===0) return edgePath0(px,py,pw,cx,cy,cw,depth,side);
if(currentLayout===1) return edgePath1(px,py,cx,cy,depth);
if(currentLayout===2) return edgePath2(px,py,pw,cx,cy,cw,depth);
if(currentLayout===3) return edgePath3(px,py,pw,cx,cy,cw,depth);
if(currentLayout===4) return edgePath4(px,py,cx,cy);
if(currentLayout===5) return edgePath5(px,py,pw,cx,cy,cw,depth);
if(currentLayout===6) return edgePath6(px,py,pw,cx,cy,cw,depth);
if(currentLayout===7) return edgePath7(px,py,pw,cx,cy,cw,depth);
return edgePath0(px,py,pw,cx,cy,cw,depth,side);
}
function switchLayout(n){
stopFD();
currentLayout=n;
Object.values(nodeMap).forEach(node=>{node._pinned=false;node._px=null;node._py=null;});
for(let i=0;i<8;i++){
const btn=document.getElementById("layout-btn-"+i);
if(btn) btn.classList.toggle("active",i===n);
}
rebuild();
resetView();
}
function nodeColor(node){
if(node.color) return node.color;
// 使用 annotate 时缓存的分支颜色,O(1) 查找
if(node._branchColor) return node._branchColor;
return "#888";
}
function el(tag,attrs){
const e=document.createElementNS(SVG_NS,tag);
if(attrs) for(const[k,v]of Object.entries(attrs)) e.setAttribute(k,v);
return e;
}
let edgeEls={}, nodeEls={};
function renderAll(tree){
document.getElementById("nodes-g").innerHTML="";
document.getElementById("edges-g").innerHTML="";
edgeEls={}; nodeEls={};
const all=[],q=[tree];
while(q.length){const n=q.shift();all.push(n);(n._depth===0?(n.branches||[]):visKids(n)).forEach(c=>q.push(c));}
const eg=document.getElementById("edges-g");
all.forEach(node=>{
const p=pos[node._id]; if(!p||p.parentId==null) return;
const pp=pos[p.parentId]; if(!pp) return;
const pNode=nodeMap[p.parentId]||tree, color=nodeColor(node), depth=node._depth, side=p.side||1;
// 线宽和透明度随深度自然收细,产生视觉层次感
const sw = depth===1 ? 2.2 : depth===2 ? 1.5 : 1.1;
const so = depth===1 ? 0.80 : depth===2 ? 0.55 : 0.38;
const path=el("path",{class:"edge",stroke:color,
"stroke-width":sw,
"stroke-opacity":so,
"stroke-linecap":"round","stroke-linejoin":"round","data-nid":node._id});
path.setAttribute("d",edgePath(pp.x,pp.y,pNode._w,p.x,p.y,node._w,depth,side,node._id));
if(pNode._collapsed) path.style.display="none";
eg.appendChild(path); edgeEls[node._id]=path;
});
const ng=document.getElementById("nodes-g");
all.forEach(node=>renderNode(node,ng));
if(currentLayout===3) drawSpine3();
applyTransform();
}
function refreshEdgesFor(id){
const p=pos[id]; if(!p) return;
const node=nodeMap[id]||TREE, path=edgeEls[id];
// 更新到父节点的边
if(path&&p.parentId!=null){
const pp=pos[p.parentId],pN=nodeMap[p.parentId]||TREE;
if(pp) path.setAttribute("d",edgePath(pp.x,pp.y,pN._w,p.x,p.y,node._w,node._depth,p.side||1));
}
// 只更新直接子节点的边(不再深度递归),用 visKids 跳过折叠子树
const kids=node._depth===0?(node.branches||[]):visKids(node);
kids.forEach(kid=>{
const cp=pos[kid._id],cp2=edgeEls[kid._id];
if(cp&&cp2) cp2.setAttribute("d",edgePath(p.x,p.y,node._w,cp.x,cp.y,kid._w,kid._depth,cp.side||1,kid._id));
// 继续向下更新(子节点位置没变,但父位置变了,所以子节点的边起点也变了)
refreshEdgesFor(kid._id);
});
}
function renderNode(node,g){
const p=pos[node._id]; if(!p) return;
const depth=node._depth, c=CFG[Math.min(depth,CFG.length-1)];
const w=node._w, h=node._h, color=nodeColor(node);
const label=node.label||node.central||"";
const kids=node.children||node.branches||[];
const grp=el("g",{class:"nd"+(node._id===selectedId?" selected":""),"data-id":node._id,
transform:`translate(p.x-w/2,p.y-h/2)`});
// selection ring
grp.appendChild(el("rect",{class:"sel-ring",x:-3,y:-3,width:w+6,height:h+6,
rx:c.rx+3,fill:"none",stroke:"#7c8cf8","stroke-width":"2","stroke-dasharray":"5 3",opacity:.8}));
// bg
const bg=el("rect",{class:"bg",width:w,height:h,rx:c.rx,ry:c.rx});
if(depth===0){bg.setAttribute("fill","url(#root-grad)");bg.setAttribute("filter","url(#glow)");}
else if(depth===1){bg.setAttribute("fill",color+"30");bg.setAttribute("stroke",color);bg.setAttribute("stroke-width","2");}
else if(depth===2){bg.setAttribute("fill",color+"18");bg.setAttribute("stroke",color+"bb");bg.setAttribute("stroke-width","1.5");}
else{bg.setAttribute("fill",color+"0e");bg.setAttribute("stroke",color+"77");bg.setAttribute("stroke-width","1");}
// label
const tc=depth<=1?"#fff":depth===2?"#e0e4f0":"#a8b0c8";
const txt=el("text",{x:w/2,y:h/2,"dominant-baseline":"central","text-anchor":"middle",
"font-size":c.fs,"font-weight":c.fw,fill:tc,style:"pointer-events:none;user-select:none;"});
txt.textContent=label;
grp.appendChild(bg); grp.appendChild(txt);
// collapse toggle
if(kids.length&&depth>0){
const bx=w-9,by=h-9;
const tg=el("g",{class:"tog","data-id":node._id});
const tc2=el("circle",{cx:bx,cy:by,r:8,fill:color+"33",stroke:color,"stroke-width":"1.2"});
const tt=el("text",{x:bx,y:by,"dominant-baseline":"central","text-anchor":"middle",
"font-size":"11","font-weight":"700",fill:color,style:"pointer-events:none;user-select:none;"});
tt.textContent=node._collapsed?"+":" −";
tg.appendChild(tc2); tg.appendChild(tt);
tg.addEventListener("mousedown",e=>e.stopPropagation());
tg.addEventListener("click",e=>{e.stopPropagation();toggle(node._id);});
grp.appendChild(tg);
}
// resize handles
grp.appendChild(el("rect",{class:"rh",x:w-HW,y:h*.15,width:HW*2,height:h*.7,rx:3,fill:color,"data-resize":"w","data-id":node._id}));
grp.appendChild(el("rect",{class:"rh-b",x:w*.15,y:h-HW,width:w*.7,height:HW*2,rx:3,fill:color,"data-resize":"h","data-id":node._id}));
grp.addEventListener("mousedown",e=>{
if(e.target.closest(".tog")||e.target.dataset.resize) return;
e.stopPropagation(); selectNode(node._id); startNodeDrag(e,node);
});
grp.addEventListener("contextmenu",e=>{
e.preventDefault(); e.stopPropagation(); selectNode(node._id); openCtxMenu(e.clientX,e.clientY,node._id);
});
g.appendChild(grp); nodeEls[node._id]=grp;
}
function patchNodeEl(node){
const grp=nodeEls[node._id]; if(!grp) return;
const p=pos[node._id]; if(!p) return;
const w=node._w,h=node._h;
grp.setAttribute("transform",`translate(p.x-w/2,p.y-h/2)`);
const bg=grp.querySelector(".bg");if(bg){bg.setAttribute("width",w);bg.setAttribute("height",h);}
const t=grp.querySelector("text[dominant-baseline]");if(t){t.setAttribute("x",w/2);t.setAttribute("y",h/2);}
const sr=grp.querySelector(".sel-ring");if(sr){sr.setAttribute("width",w+6);sr.setAttribute("height",h+6);}
const rh=grp.querySelector("[data-resize='w']");if(rh){rh.setAttribute("x",w-HW);rh.setAttribute("y",h*.15);rh.setAttribute("height",h*.7);}
const rb=grp.querySelector("[data-resize='h']");if(rb){rb.setAttribute("x",w*.15);rb.setAttribute("y",h-HW);rb.setAttribute("width",w*.7);}
const tg=grp.querySelector(".tog");
if(tg){const bc=tg.querySelector("circle"),bt=tg.querySelector("text");
if(bc){bc.setAttribute("cx",w-9);bc.setAttribute("cy",h-9);}
if(bt){bt.setAttribute("x",w-9);bt.setAttribute("y",h-9);}}
}
/* ══ SELECTION ══ */
function selectNode(id){
if(selectedId&&nodeEls[selectedId]) nodeEls[selectedId].classList.remove("selected");
selectedId=id;
if(id&&nodeEls[id]) nodeEls[id].classList.add("selected");
}
/* ══ CONTEXT MENU ══ */
function buildColorDots(){
const cont=document.getElementById("ctx-colors"); cont.innerHTML="";
const curNode=nodeMap[ctxTargetId];
const curColor=curNode?.color||null;
PALETTE.forEach(c=>{
const d=document.createElement("div");
d.className="color-dot"+(c===curColor?" active":"");
d.style.background=c; d.title=c;
d.onclick=()=>ctxAction("color",c); cont.appendChild(d);
});
const r=document.createElement("div");
r.className="color-dot"+(curColor===null?" active":"");
r.style.background="rgba(255,255,255,.15)";
r.style.cssText+=";font-size:11px;display:flex;align-items:center;justify-content:center;";
r.title="自动颜色";r.textContent="↺";r.onclick=()=>ctxAction("color",null);
cont.appendChild(r);
}
function openCtxMenu(x,y,id){
ctxTargetId=id; buildColorDots();
// Update color label to show what will be changed
const lbl=document.getElementById("ctx-color-label");
if(lbl){
const n=nodeMap[id];
const depth=n?n._depth:0;
if(depth===0) lbl.textContent="更改根节点颜色";
else if(depth===1) lbl.textContent="更改分支颜色(影响子节点默认色)";
else lbl.textContent="更改此节点颜色";
}
const menu=document.getElementById("ctx-menu"); menu.classList.add("open");
menu.style.left=x+"px"; menu.style.top=y+"px";
requestAnimationFrame(()=>{
const r=menu.getBoundingClientRect();
if(r.right>window.innerWidth) menu.style.left=(x-r.width)+"px";
if(r.bottom>window.innerHeight) menu.style.top=(y-r.height)+"px";
});
}
function closeCtxMenu(){document.getElementById("ctx-menu").classList.remove("open");ctxTargetId=null;}
function ctxAction(action,extra){
// Save target id BEFORE closeCtxMenu() nulls ctxTargetId
const targetId=ctxTargetId;
closeCtxMenu();
const node=nodeMap[targetId]; if(!node&&action!=="delete") return;
if(action==="add-child"){
snapshotForUndo();
const d=Math.min(node._depth+1,CFG.length-1);
const nn={_id:newId(),label:"新节点",children:[],_depth:d,_collapsed:false,_pinned:false,_px:null,_py:null};
nn._w=measureW("新节点",d); nn._h=CFG[d].h; nodeMap[nn._id]=nn;
// Root node uses .branches, all others use .children
if(node._depth===0){
if(!node.branches) node.branches=[];
node.branches.push(nn);
} else {
if(!node.children) node.children=[];
node.children.push(nn);
}
rebuild();
setTimeout(()=>selectNode(nn._id), 30);
return;
}
if(action==="add-sibling"){
snapshotForUndo();
const p=pos[targetId]; if(!p||!p.parentId){showToast("根节点无法添加兄弟节点");return;}
const parent=nodeMap[p.parentId]||TREE;
const arr=parent._depth===0?(parent.branches||[]):(parent.children||[]);
const idx=arr.findIndex(c=>c._id===targetId);
const d=node._depth;
const nn={_id:newId(),label:"新节点",children:[],_depth:d,_collapsed:false,_pinned:false,_px:null,_py:null};
nn._w=measureW("新节点",d); nn._h=CFG[Math.min(d,CFG.length-1)].h; nodeMap[nn._id]=nn;
arr.splice(idx+1,0,nn); rebuild();
setTimeout(()=>selectNode(nn._id), 30);
return;
}
if(action==="delete"){
if(!node){return;} if(node._depth===0){showToast("不能删除根节点");return;}
snapshotForUndo();
const p=pos[targetId]; if(!p||!p.parentId) return;
const parent=nodeMap[p.parentId]||TREE;
const arr=parent._depth===0?(parent.branches||[]):(parent.children||[]);
const idx=arr.findIndex(c=>c._id===targetId);
if(idx>=0) arr.splice(idx,1);
function rm(n){delete nodeMap[n._id];(n.children||[]).forEach(rm);}
rm(node);
if(selectedId===targetId) selectNode(null);
rebuild(); return;
}
if(action==="color"){
snapshotForUndo();
// Set color on the exact node clicked — no walk-up to branch root
if(extra===null) delete node.color; else node.color=extra;
rebuild(); return;
}
}
// Use mousedown (not click) to close menu so it doesn't race with menu item onclick
document.addEventListener("mousedown",e=>{
const menu=document.getElementById("ctx-menu");
if(menu.classList.contains("open")&&!menu.contains(e.target)) closeCtxMenu();
});
document.addEventListener("contextmenu",e=>{
if(["wrap","svg","edges-g","nodes-g"].includes(e.target.id)||(e.target.tagName==="svg")||(e.target.parentElement&&e.target.parentElement.id==="edges-g"))
e.preventDefault();
});
/* ══ UNDO / REDO ══════════════════════════════════════════════════════════
操作前调用 snapshotForUndo(),将当前树结构序列化压入撤销栈。
Ctrl+Z 弹出并恢复,Ctrl+Y/Ctrl+Shift+Z 重做。
════════════════════════════════════════════════════════════════════════ */
function _treeSnapshot(){
// 序列化当前树(含颜色、折叠状态、位置)
function snap(node){
const out={label:node.label,central:node.central,color:node.color,
_collapsed:node._collapsed,_pinned:node._pinned,_px:node._px,_py:node._py,
_w:node._w,_h:node._h};
if((node.children||[]).length) out.children=(node.children||[]).map(snap);
if((node.branches||[]).length) out.branches=(node.branches||[]).map(snap);
return out;
}
return JSON.stringify(snap(TREE));
}
function _restoreSnapshot(json){
const saved=JSON.parse(json);
function restore(live,saved){
live.label=saved.label; live.central=saved.central;
if(saved.color) live.color=saved.color; else delete live.color;
live._collapsed=saved._collapsed||false;
live._pinned=saved._pinned||false;
live._px=saved._px??null; live._py=saved._py??null;
live._w=saved._w||null; live._h=saved._h||null;
// Rebuild children array from saved data
if(saved.children){
live.children=(saved.children).map(sc=>{
const n={label:sc.label||"",children:[],branches:[]};
restore(n,sc); return n;
});
} else { live.children=[]; }
if(saved.branches){
live.branches=(saved.branches).map(sb=>{
const n={label:sb.label||"",children:[],branches:[]};
restore(n,sb); return n;
});
}
}
restore(TREE,saved);
// 清理所有可能持有旧节点引用的状态,防止 stale reference crash
activeOp = null;
wrap.style.cursor = "";
selectNode(null);
ctxTargetId = null;
_pushScheduled = false;
// 关键修复:_nid 重置为 0 后,必须清除 TREE._id,否则 TREE 保留旧 _id(如 "n1"),
// 而 annotate 从 n1 开始分配,导致第一个分支也拿到 "n1",产生 ID 碰撞,
// nodeMap["n1"] 被分支覆盖,TREE 从 nodeMap 消失,渲染完全混乱。
delete TREE._id;
_nid=0; nodeMap={};
annotate(TREE,0,null);
rebuild();
}
function snapshotForUndo(){
_undoStack.push(_treeSnapshot());
if(_undoStack.length>MAX_UNDO) _undoStack.shift();
_redoStack=[]; // new action clears redo
}
function undo(){
if(!_undoStack.length){ showToast("没有可撤销的操作",1600); return; }
_redoStack.push(_treeSnapshot());
_restoreSnapshot(_undoStack.pop());
showToast("↩ 已撤销",1400);
}
function redo(){
if(!_redoStack.length){ showToast("没有可重做的操作",1600); return; }
_undoStack.push(_treeSnapshot());
_restoreSnapshot(_redoStack.pop());
showToast("↪ 已重做",1400);
}
/* ══ KEYBOARD ══ */
document.addEventListener("keydown",e=>{
// Undo / Redo
if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&e.key==="z"){e.preventDefault();undo();return;}
if((e.ctrlKey||e.metaKey)&&(e.key==="y"||(e.shiftKey&&e.key==="z"))){e.preventDefault();redo();return;}
if((e.key==="Delete"||e.key==="Backspace")&&selectedId){
e.preventDefault();
ctxTargetId=selectedId; ctxAction("delete"); return;
}
if(e.key==="Tab"&&selectedId){
e.preventDefault();
ctxTargetId=selectedId; ctxAction("add-child"); return;
}
if(e.key==="Escape"){selectNode(null);closeCtxMenu();}
});
/* ══ DRAG REPULSION ══════════════════════════════════════════════════════
链式传播算法:
1. 以被拖动节点为压力源,计算每个节点到压力源的距离
2. 按距离从近到远排序,依次推开——近的节点先让位,压力向外传播
3. 推开时近压力源的节点固定(已被推过),只推远端节点
4. 多轮迭代直到全局无重叠,避免振荡
════════════════════════════════════════════════════════════════════════ */
const DRAG_PAD = 10;
const MAX_ITER = 15;
function pushAway(draggedId){
const allIds = Object.keys(pos);
if(allIds.length < 2) return;
// 预计算半尺寸
const hw = {}, hh = {};
allIds.forEach(id => {
const n = nodeMap[id] || TREE;
hw[id] = n._w/2 + DRAG_PAD/2;
hh[id] = n._h/2 + DRAG_PAD/2;
});
const dp = pos[draggedId];
for(let iter = 0; iter < MAX_ITER; iter++){
let anyOverlap = false;
// 按到拖动节点的距离从近到远排序,让压力从内向外传播
const sorted = allIds.slice().sort((a, b) => {
const pa = pos[a], pb = pos[b];
const da = (pa.x-dp.x)**2 + (pa.y-dp.y)**2;
const db = (pb.x-dp.x)**2 + (pb.y-dp.y)**2;
return da - db;
});
for(let i = 0; i < sorted.length; i++){
for(let j = i+1; j < sorted.length; j++){
const ai = sorted[i], aj = sorted[j]; // ai 比 aj 更靠近拖动节点
const pi = pos[ai], pj = pos[aj];
if(!pi || !pj) continue;
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const overlapX = (hw[ai] + hw[aj]) - Math.abs(dx);
const overlapY = (hh[ai] + hh[aj]) - Math.abs(dy);
if(overlapX <= 0 || overlapY <= 0) continue;
anyOverlap = true;
// 关键:ai 更靠近压力源(已被处理过),固定 ai 只推 aj
// 压力单向向外传播,不会产生振荡
if(overlapX <= overlapY){
pj.x += overlapX * (dx >= 0 ? 1 : -1);
} else {
pj.y += overlapY * (dy >= 0 ? 1 : -1);
}
}
}
if(!anyOverlap) break;
}
// 同步视觉、side 和 pinned 坐标
allIds.forEach(id => {
const p = pos[id]; if(!p) return;
const n = nodeMap[id] || TREE;
// 更新 side:始终以父节点为参照
const parentP = p.parentId ? pos[p.parentId] : null;
if(parentP) p.side = p.x >= parentP.x ? 1 : -1;
if(n._pinned){ n._px = p.x; n._py = p.y; }
patchNodeEl(n);
refreshEdgesFor(id);
});
}
/* ══ GLOBAL SEPARATION ═══════════════════════════════════════════════════
布局完成后对所有节点做一次全局分离,确保不重叠。
与 pushAway 的区别:没有固定压力源,每对重叠节点各自向外移动一半,
适合初始布局、切换布局、添加/删除节点后的全局整理。
════════════════════════════════════════════════════════════════════════ */
function separateAll(){
const allIds = Object.keys(pos);
if(allIds.length < 2) return;
const hw = {}, hh = {};
allIds.forEach(id => {
const n = nodeMap[id] || TREE;
hw[id] = n._w/2 + DRAG_PAD/2;
hh[id] = n._h/2 + DRAG_PAD/2;
});
// 预处理:给完全重合的节点施加微小扰动,防止对称死锁
for(let i = 0; i < allIds.length; i++){
for(let j = i+1; j < allIds.length; j++){
const pi = pos[allIds[i]], pj = pos[allIds[j]];
if(!pi||!pj) continue;
if(Math.abs(pi.x-pj.x)<0.1 && Math.abs(pi.y-pj.y)<0.1){
// 按索引差给一个确定性的角度扰动,避免随机性
const angle = (j - i) * 2.399; // 黄金角,均匀分布
pj.x += Math.cos(angle) * 0.5;
pj.y += Math.sin(angle) * 0.5;
}
}
}
const SEP_ITER = allIds.length * 4; // 实测:n*4 覆盖 99% 的实际场景,50节点以内 <10ms
for(let iter = 0; iter < SEP_ITER; iter++){
let anyOverlap = false;
for(let i = 0; i < allIds.length; i++){
for(let j = i+1; j < allIds.length; j++){
const ai = allIds[i], aj = allIds[j];
const pi = pos[ai], pj = pos[aj];
if(!pi || !pj) continue;
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const overlapX = (hw[ai] + hw[aj]) - Math.abs(dx);
const overlapY = (hh[ai] + hh[aj]) - Math.abs(dy);
if(overlapX <= 0 || overlapY <= 0) continue;
anyOverlap = true;
const fixI = (nodeMap[ai]||TREE)._depth === 0;
const fixJ = (nodeMap[aj]||TREE)._depth === 0;
if(overlapX <= overlapY){
const push = overlapX * (dx >= 0 ? 1 : -1);
if(!fixI && !fixJ){ pi.x -= push*0.5; pj.x += push*0.5; }
else if(fixI) pj.x += push;
else pi.x -= push;
} else {
const push = overlapY * (dy >= 0 ? 1 : -1);
if(!fixI && !fixJ){ pi.y -= push*0.5; pj.y += push*0.5; }
else if(fixI) pj.y += push;
else pi.y -= push;
}
}
}
if(!anyOverlap) break;
}
// 同步 side / pinned 坐标
allIds.forEach(id => {
const p = pos[id]; if(!p) return;
const n = nodeMap[id] || TREE;
const parentP = p.parentId ? pos[p.parentId] : null;
if(parentP) p.side = p.x >= parentP.x ? 1 : -1;
if(n._pinned){ n._px = p.x; n._py = p.y; }
});
}
/* ══ INTERACTION ══ */
let activeOp=null, T={x:0,y:0,s:1};
function applyTransform(){
const t=`translate(T.x,T.y) scale(T.s)`;
document.getElementById("edges-g").setAttribute("transform",t);
document.getElementById("nodes-g").setAttribute("transform",t);
}
function svgXY(cx,cy){return{x:(cx-T.x)/T.s,y:(cy-T.y)/T.s};}
function startNodeDrag(e,node){const sv=svgXY(e.clientX,e.clientY);activeOp={type:"nodedrag",node,ox:sv.x-pos[node._id].x,oy:sv.y-pos[node._id].y,moved:false};}
function startResizeW(e,node){e.stopPropagation();const sv=svgXY(e.clientX,e.clientY);activeOp={type:"rw",node,sx:sv.x,sw:node._w};}
function startResizeH(e,node){e.stopPropagation();const sv=svgXY(e.clientX,e.clientY);activeOp={type:"rh",node,sy:sv.y,sh:node._h};}
window.addEventListener("mousemove",e=>{
if(!activeOp) return;
if(activeOp.type==="canvas"){T.x=e.clientX-activeOp.sx;T.y=e.clientY-activeOp.sy;applyTransform();return;}
if(activeOp.type==="nodedrag"){
const sv=svgXY(e.clientX,e.clientY);
if(!activeOp.moved&&Math.hypot(sv.x-pos[activeOp.node._id].x-activeOp.ox,sv.y-pos[activeOp.node._id].y-activeOp.oy)<2) return;
activeOp.moved=true;
const node=activeOp.node;
node._pinned=true; node._px=sv.x-activeOp.ox; node._py=sv.y-activeOp.oy;
pos[node._id].x=node._px; pos[node._id].y=node._py;
// 实时更新 side:节点在父节点哪侧由实际坐标决定
const _pp=pos[pos[node._id].parentId]; if(_pp) pos[node._id].side=pos[node._id].x>=_pp.x?1:-1;
patchNodeEl(node); refreshEdgesFor(node._id);
// rAF throttle: 每帧最多执行一次 pushAway,避免高频 mousemove 掉帧
if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(node._id);});}
return;
}
if(activeOp.type==="rw"){const sv=svgXY(e.clientX,e.clientY);activeOp.node._w=Math.max(MIN_W,activeOp.sw+(sv.x-activeOp.sx));patchNodeEl(activeOp.node);refreshEdgesFor(activeOp.node._id);if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(activeOp.node._id);});}return;}
if(activeOp.type==="rh"){const sv=svgXY(e.clientX,e.clientY);activeOp.node._h=Math.max(MIN_H,activeOp.sh+(sv.y-activeOp.sy));patchNodeEl(activeOp.node);refreshEdgesFor(activeOp.node._id);if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(activeOp.node._id);});}return;}
});
window.addEventListener("mouseup",()=>{
if(!activeOp) return;
if(activeOp.type==="nodedrag"){
if(!activeOp.moved){
const node=activeOp.node,kids=node.children||node.branches||[];
if(kids.length&&node._depth>0) toggle(node._id);
} else {
snapshotForUndo(); // 拖动结束后保存快照
}
}
if(activeOp.type==="rw"||activeOp.type==="rh") snapshotForUndo();
activeOp=null; wrap.style.cursor="";
});
const wrap=document.getElementById("wrap");
wrap.addEventListener("mousedown",e=>{
if(activeOp) return;
if(e.target===e.currentTarget||e.target.tagName==="svg"||["edges-g","nodes-g"].includes(e.target.id))
{selectNode(null);closeCtxMenu();}
activeOp={type:"canvas",sx:e.clientX-T.x,sy:e.clientY-T.y}; wrap.style.cursor="grabbing";
});
document.getElementById("nodes-g").addEventListener("mousedown",e=>{
const rt=e.target.dataset.resize,nid=e.target.dataset.id; if(!rt||!nid) return;
e.stopPropagation(); const node=nodeMap[nid]; if(!node) return;
if(rt==="w")startResizeW(e,node); if(rt==="h")startResizeH(e,node);
});
wrap.addEventListener("wheel",e=>{
e.preventDefault();
const r=wrap.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
const f=e.deltaY<0?1.11:0.9, ns=Math.min(Math.max(T.s*f,0.1),6);
T.x=mx-(ns/T.s)*(mx-T.x); T.y=my-(ns/T.s)*(my-T.y); T.s=ns; applyTransform();
},{passive:false});
let tDrag=null;
wrap.addEventListener("touchstart",e=>{if(e.touches.length===1)tDrag={sx:e.touches[0].clientX-T.x,sy:e.touches[0].clientY-T.y};},{passive:true});
wrap.addEventListener("touchmove",e=>{if(tDrag&&e.touches.length===1){T.x=e.touches[0].clientX-tDrag.sx;T.y=e.touches[0].clientY-tDrag.sy;applyTransform();}},{passive:true});
wrap.addEventListener("touchend",()=>tDrag=null);
/* ══ COLLAPSE ══ */
function toggle(id){const n=nodeMap[id];if(!n)return;n._collapsed=!n._collapsed;rebuild();}
function expandAll(){Object.values(nodeMap).forEach(n=>n._collapsed=false);rebuild();}
function collapseAll(){Object.values(nodeMap).forEach(n=>{if(n._depth>=1)n._collapsed=true;});rebuild();}
function rebuild(){
layout(TREE);
separateAll(); // 布局后全局分离,确保不重叠
renderAll(TREE);
}
function resetView(){T={x:wrap.clientWidth/2,y:wrap.clientHeight/2,s:1};applyTransform();}
function zoomIn(){T.s=Math.min(T.s*1.2,6);applyTransform();}
function zoomOut(){T.s=Math.max(T.s/1.2,0.1);applyTransform();}
/* ══ TOAST ══ */
function showToast(msg,dur=2400){const t=document.getElementById("toast");t.textContent=msg;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),dur);}
/* ══ EXPORT ══ */
function getBounds(){
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
[...Object.values(nodeMap),TREE].forEach(n=>{
const p=pos[n._id];if(!p)return;
x0=Math.min(x0,p.x-n._w/2);x1=Math.max(x1,p.x+n._w/2);
y0=Math.min(y0,p.y-n._h/2);y1=Math.max(y1,p.y+n._h/2);
});
const pad=60; return{x0:x0-pad,y0:y0-pad,w:x1-x0+pad*2,h:y1-y0+pad*2};
}
function buildExportSVG(){
const b=getBounds();
const clone=document.getElementById("svg").cloneNode(true);
clone.querySelectorAll(".rh,.rh-b,.tog,.sel-ring").forEach(e=>e.remove());
clone.querySelectorAll(".nd").forEach(g=>g.classList.remove("selected"));
clone.querySelectorAll("#edges-g,#nodes-g").forEach(g=>g.removeAttribute("transform"));
clone.setAttribute("viewBox",`b.x0 b.y0 b.w b.h`);
clone.setAttribute("width",Math.round(b.w)); clone.setAttribute("height",Math.round(b.h));
clone.style.cssText="";
const bg=document.createElementNS(SVG_NS,"rect");
bg.setAttribute("x",b.x0);bg.setAttribute("y",b.y0);bg.setAttribute("width",b.w);bg.setAttribute("height",b.h);bg.setAttribute("fill","#0d0f1a");
clone.insertBefore(bg,clone.firstChild);
const st=document.createElementNS(SVG_NS,"style");
st.textContent='text{font-family:-apple-system,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;}';
clone.insertBefore(st,clone.firstChild);
return{svgEl:clone,b};
}
function dlBlob(blob,name){
const url=URL.createObjectURL(blob),a=document.createElement("a");
a.href=url;a.download=name;document.body.appendChild(a);a.click();
setTimeout(()=>{document.body.removeChild(a);URL.revokeObjectURL(url);},300);
}
function exportAs(fmt){
showToast("正在导出 "+fmt.toUpperCase()+" …");
const{svgEl,b}=buildExportSVG();
const svgStr=new XMLSerializer().serializeToString(svgEl);
const safe=TITLE.replace(/[\\/:*?"<>|]/g,"_");
if(fmt==="svg"){dlBlob(new Blob([svgStr],{type:"image/svg+xml"}),safe+".svg");return;}
const sc=fmt==="jpg"?2:2.5;
const canvas=document.createElement("canvas"); canvas.width=b.w*sc; canvas.height=b.h*sc;
const ctx=canvas.getContext("2d");
if(fmt==="jpg"){ctx.fillStyle="#0d0f1a";ctx.fillRect(0,0,canvas.width,canvas.height);}
const img=new Image();
const bUrl=URL.createObjectURL(new Blob([svgStr],{type:"image/svg+xml"}));
img.onload=()=>{
ctx.drawImage(img,0,0,canvas.width,canvas.height); URL.revokeObjectURL(bUrl);
if(fmt==="png") canvas.toBlob(bl=>dlBlob(bl,safe+".png"),"image/png");
else if(fmt==="jpg") canvas.toBlob(bl=>dlBlob(bl,safe+".jpg"),"image/jpeg",0.93);
else if(fmt==="pdf") makePDF(canvas,b,safe);
};
img.src=bUrl;
}
function makePDF(canvas,b,safe){
const jData=canvas.toDataURL("image/jpeg",0.92).split(",")[1];
const jBytes=Uint8Array.from(atob(jData),c=>c.charCodeAt(0));
const W=Math.round(b.w),H=Math.round(b.h);
const enc=new TextEncoder();
function str(s){return enc.encode(s);}
const stream=`q W 0 0 H 0 0 cm /Im1 Do Q`;
const objs=[str("%PDF-1.4\n"),str("1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n"),
str(`2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n`),
str(`3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 W H]/Contents 4 0 R/Resources<</XObject<</Im1 5 0 R>>>>>>\nendobj\n`),
str(`4 0 obj\n<</Length stream.length>>\nstream\nstream\nendstream\nendobj\n`),
str(`5 0 obj\n<</Type/XObject/Subtype/Image/Width canvas.width/Height canvas.height/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/DCTDecode/Length jBytes.length>>\nstream\n`),
jBytes,str("\nendstream\nendobj\n"),
str("xref\n0 6\n0000000000 65535 f \ntrailer\n<</Size 6/Root 1 0 R>>\nstartxref\n0\n%%EOF\n")];
const total=objs.reduce((s,o)=>s+o.length,0); const buf=new Uint8Array(total); let off=0;
for(const o of objs){buf.set(o,off);off+=o.length;}
dlBlob(new Blob([buf],{type:"application/pdf"}),safe+".pdf");
}
/* ══ XMIND ══ */
function exportXmind(){
showToast("正在生成 XMind …");
// ── helpers ────────────────────────────────────────────────────────────
function uid(){ return crypto.randomUUID().replace(/-/g,"").slice(0,26); }
function xe(s){ return String(s).replace(/&/g,"&").replace(/</g,"<")
.replace(/>/g,">").replace(/"/g,"""); }
// ── content.json (XMind 2020+) ────────────────────────────────────────
function xnJson(node){
const kids=(node.branches||[]).concat(node.children||[]);
const o={id:uid(),class:"topic",title:node.label||node.central||""};
if(kids.length) o.children={attached:kids.map(xnJson)};
if(node.color) o.style={id:uid(),properties:{
"line-color":node.color,"background-color":node.color+"33",
"border-line-color":node.color,"line-width":"2pt",
"shape-class":"org.xmind.topicShape.roundedRect"}};
return o;
}
const rootJson=xnJson(TREE);
rootJson.structureClass="org.xmind.ui.map.unbalanced";
const contentJson=[{id:uid(),class:"sheet",title:TITLE,rootTopic:rootJson,theme:{},extensions:[]}];
// ── content.xml (XMind 8) ─────────────────────────────────────────────
function xnXml(node, depth){
const kids=(node.branches||[]).concat(node.children||[]);
const label=node.label||node.central||"";
const ind=" ".repeat(depth);
let s=`ind<topic id="uid()"`;
if(depth===0) s+=' structure-class="org.xmind.ui.map.unbalanced"';
s+=`>\nind <title>xe(label)</title>`;
if(kids.length){
s+=`\nind <children>\nind <topics type="attached">`;
for(const c of kids) s+="\n"+xnXml(c,depth+3);
s+=`\nind </topics>\nind </children>`;
}
s+=`\nind</topic>`;
return s;
}
const sheetId=uid();
const xmlRoot=xnXml(TREE,0);
const contentXml=`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n`+
`<xmap-content xmlns="urn:xmind:xmap:xmlns:content:2.0"\n`+
` xmlns:fo="http://www.w3.org/1999/XSL/Format"\n`+
` xmlns:svg="http://www.w3.org/2000/svg"\n`+
` xmlns:xhtml="http://www.w3.org/1999/xhtml"\n`+
` xmlns:xlink="http://www.w3.org/1999/xlink"\n`+
` version="2.0">\n`+
` <sheet id="sheetId">\n`+
xmlRoot+"\n"+
` <title>xe(TITLE)</title>\n`+
` </sheet>\n`+
`</xmap-content>`;
const stylesXml=`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n`+
`<xmap-styles xmlns="urn:xmind:xmap:xmlns:style:2.0" version="2.0"></xmap-styles>`;
// ── metadata & manifest ────────────────────────────────────────────────
const meta={modifier:"",created:new Date().toISOString().slice(0,19)+".000+0000",
creator:{name:"OpenClaw MindMap",version:"5.0",platform:""}};
const mf={"file-entries":{
"content.json":{"media-type":"application/json"},
"content.xml": {"media-type":"text/xml"},
"styles.xml": {"media-type":"text/xml"},
"metadata.json":{"media-type":"application/json"},
"manifest.json":{"media-type":"application/json"}}};
// ── ZIP builder ────────────────────────────────────────────────────────
function u16(v){const b=new Uint8Array(2);new DataView(b.buffer).setUint16(0,v,true);return b;}
function u32(v){const b=new Uint8Array(4);new DataView(b.buffer).setUint32(0,v,true);return b;}
function crc32(d){
if(!crc32.t){crc32.t=new Uint32Array(256);for(let i=0;i<256;i++){let c=i;for(let j=0;j<8;j++)c=c&1?0xEDB88320^(c>>>1):c>>>1;crc32.t[i]=c;}}
let c=0xFFFFFFFF;for(const b of d)c=crc32.t[(c^b)&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
}
function cat(...a){const t=a.reduce((s,x)=>s+x.length,0),o=new Uint8Array(t);let p=0;for(const x of a){o.set(x,p);p+=x.length;}return o;}
const enc=new TextEncoder();
const files=[
["manifest.json", enc.encode(JSON.stringify(mf,null,2))],
["content.json", enc.encode(JSON.stringify(contentJson,null,2))],
["content.xml", enc.encode(contentXml)],
["styles.xml", enc.encode(stylesXml)],
["metadata.json", enc.encode(JSON.stringify(meta,null,2))],
];
const lParts=[],cds=[];let dataOff=0;
for(const[name,data]of files){
const nb=enc.encode(name),crc=crc32(data),sz=data.length;
const lh=cat(new Uint8Array([0x50,0x4B,0x03,0x04]),u16(20),u16(0),u16(0),u16(0),u16(0),
u32(crc),u32(sz),u32(sz),u16(nb.length),u16(0),nb);
const cd=cat(new Uint8Array([0x50,0x4B,0x01,0x02]),u16(20),u16(20),u16(0),u16(0),u16(0),u16(0),
u32(crc),u32(sz),u32(sz),u16(nb.length),u16(0),u16(0),u16(0),u16(0),u32(0),u32(dataOff),nb);
lParts.push(lh,data);cds.push(cd);dataOff+=lh.length+sz;
}
const cdBytes=cat(...cds);
const eocd=cat(new Uint8Array([0x50,0x4B,0x05,0x06]),u16(0),u16(0),
u16(files.length),u16(files.length),u32(cdBytes.length),u32(dataOff),u16(0));
dlBlob(new Blob([cat(...lParts,cdBytes,eocd)],{type:"application/octet-stream"}),
TITLE.replace(/[\\/:*?"<>|]/g,"_")+".xmind");
}
/* ══ BOOT ══ */
RAW._depth=0; RAW.label=RAW.central;
annotate(RAW,0);
const TREE=RAW;
layout(TREE);
separateAll(); // 初始布局后分离
renderAll(TREE);
resetView();
</script>
</body>
</html>"""
def render_html(title, js_data):
return (_HTML
.replace("__TITLE__", title)
.replace("__RAW_JSON__", js_data)
.replace("__TITLE_JSON__", json.dumps(title, ensure_ascii=False))
.replace("__DATE__", datetime.now().strftime("%Y-%m-%d %H:%M")))
# ═══════════════════════════════════════════════════════════════════════════════
# IMAGE / PDF EXPORT — Two backends, auto-selected by availability:
#
# 1. Playwright (best quality — renders full interactive HTML in Chromium)
# 2. Pillow (pure Python — works everywhere, only needs `pip install pillow`)
#
# The script tries each backend in order and uses the first one that works.
# On a typical Windows machine only Pillow is available, and that's fine.
# Pillow is auto-installed if missing.
# ═══════════════════════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────────────────────────────────────
# Font discovery (cross-platform: Windows / macOS / Linux)
# ─────────────────────────────────────────────────────────────────────────────
_FONT_SEARCH_PATHS = {
"Windows": [
# CJK fonts commonly found on Windows
r"C:\Windows\Fonts\msyh.ttc", # 微软雅黑
r"C:\Windows\Fonts\msyhbd.ttc", # 微软雅黑 Bold
r"C:\Windows\Fonts\simhei.ttf", # 黑体
r"C:\Windows\Fonts\simsun.ttc", # 宋体
r"C:\Windows\Fonts\simkai.ttf", # 楷体
r"C:\Windows\Fonts\STFANGSO.TTF", # 华文仿宋
r"C:\Windows\Fonts\STSONG.TTF", # 华文宋体
r"C:\Windows\Fonts\malgun.ttf", # Malgun Gothic (Korean but has CJK)
r"C:\Windows\Fonts\meiryo.ttc", # Meiryo (Japanese but has CJK)
r"C:\Windows\Fonts\segoeui.ttf", # Fallback: Segoe UI (no CJK)
r"C:\Windows\Fonts\arial.ttf", # Fallback: Arial
],
"Darwin": [ # macOS
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/Library/Fonts/Arial Unicode MS.ttf",
"/System/Library/Fonts/Helvetica.ttc",
],
"Linux": [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/opentype/unifont/unifont.otf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
],
}
_font_cache = {} # (size, bold) → ImageFont
def _find_system_font():
"""Return the path to the best available font for the current OS."""
system = platform.system()
candidates = _FONT_SEARCH_PATHS.get(system, [])
# Also merge Linux paths as generic fallback
if system != "Linux":
candidates = candidates + _FONT_SEARCH_PATHS["Linux"]
for p in candidates:
if Path(p).is_file():
return p
return None
def _get_font(size, bold=False):
"""Get a PIL ImageFont at the given size, with caching."""
key = (size, bold)
if key in _font_cache:
return _font_cache[key]
from PIL import ImageFont
font_path = _find_system_font()
if font_path:
try:
# For .ttc files, index 0 is usually Regular, index 1 is Bold
idx = 1 if bold and font_path.endswith((".ttc", ".TTC")) else 0
fnt = ImageFont.truetype(font_path, size, index=idx)
_font_cache[key] = fnt
return fnt
except Exception:
try:
fnt = ImageFont.truetype(font_path, size)
_font_cache[key] = fnt
return fnt
except Exception:
pass
# Ultimate fallback: Pillow default bitmap font
fnt = ImageFont.load_default()
_font_cache[key] = fnt
return fnt
# ─────────────────────────────────────────────────────────────────────────────
# Backend 1: Playwright (best quality — renders full interactive HTML)
# ─────────────────────────────────────────────────────────────────────────────
def _has_playwright():
try:
from playwright.sync_api import sync_playwright as _sp # noqa: F401
return True
except ImportError:
return False
def _export_image_playwright(html_str: str, out: str, fmt: str,
scale: float = 2.0, quality: int = 92):
import tempfile, shutil
pw_paths = [
os.environ.get("PLAYWRIGHT_BROWSERS_PATH", ""),
"/opt/pw-browsers",
str(Path.home() / ".cache" / "ms-playwright"),
]
for p in pw_paths:
if p and Path(p).is_dir():
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = p
break
from playwright.sync_api import sync_playwright
tmp_dir = tempfile.mkdtemp(prefix="mindmap_")
tmp_html = os.path.join(tmp_dir, "mindmap.html")
with open(tmp_html, "w", encoding="utf-8") as f:
f.write(html_str)
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True, args=[
"--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
])
dpr = max(1.0, min(scale, 4.0))
page = browser.new_page(
viewport={"width": 2560, "height": 1440},
device_scale_factor=dpr,
)
page.goto(f"file://{tmp_html}", wait_until="networkidle")
page.wait_for_selector("#nodes-g .nd", timeout=8000)
page.wait_for_timeout(600)
bbox = page.evaluate(r"""() => {
const nodesG = document.getElementById('nodes-g');
const edgesG = document.getElementById('edges-g');
if (!nodesG) return null;
let minX=Infinity, minY=Infinity, maxX=-Infinity, maxY=-Infinity;
for (const g of [nodesG, edgesG]) {
if (!g) continue;
for (const el of g.querySelectorAll('rect,text,path,circle')) {
const r = el.getBoundingClientRect();
if (r.width===0 && r.height===0) continue;
if (r.left<minX) minX=r.left; if (r.top<minY) minY=r.top;
if (r.right>maxX) maxX=r.right; if (r.bottom>maxY) maxY=r.bottom;
}
}
if (minX===Infinity) return null;
const pad=40;
return {x:Math.max(0,minX-pad),y:Math.max(0,minY-pad),
width:maxX-minX+pad*2,height:maxY-minY+pad*2};
}""")
clip = bbox if (bbox and bbox["width"] > 0) else None
if not clip:
wrap = page.query_selector("#wrap")
clip = wrap.bounding_box() if wrap else None
png_bytes = page.screenshot(type="png", clip=clip) if clip else page.screenshot(type="png")
browser.close()
from PIL import Image as PILImage
img = PILImage.open(io.BytesIO(png_bytes))
if fmt == "jpg":
if img.mode in ("RGBA", "P"):
bg = PILImage.new("RGB", img.size, (13, 15, 26))
if img.mode == "P": img = img.convert("RGBA")
bg.paste(img, mask=img.split()[3]); img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
img.save(out, "JPEG", quality=quality, optimize=True)
else:
img.save(out, "PNG", optimize=True)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
# ─────────────────────────────────────────────────────────────────────────────
# Backend 2: Pure Pillow (works everywhere — only needs `pip install pillow`)
# ─────────────────────────────────────────────────────────────────────────────
def _pillow_measure_text(text, font):
"""Measure text width and height using the font."""
bbox = font.getbbox(str(text))
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def _strip_emoji_for_pillow(text):
"""Remove emoji characters that Pillow/system fonts can't render.
Keeps the text content after the emoji prefix."""
if not text:
return text
result = []
i = 0
while i < len(text):
cp = ord(text[i])
# Skip emoji code points
if (cp >= 0x1F300 or # Misc Symbols & Pictographs+
0x2600 <= cp <= 0x27BF or # Misc Symbols, Dingbats
0x2300 <= cp <= 0x23FF or # Misc Technical
cp == 0xFE0F or cp == 0xFE0E or # Variation Selectors
cp == 0x200D or # Zero Width Joiner
0xE0020 <= cp <= 0xE007F or # Tags
0x1F900 <= cp <= 0x1F9FF or # Supplemental Symbols
0x1FA00 <= cp <= 0x1FA6F or # Chess Symbols
0x1FA70 <= cp <= 0x1FAFF or # Symbols Extended-A
0x2702 <= cp <= 0x27B0 or # Dingbats
0x231A <= cp <= 0x231B or # Watch, Hourglass
cp == 0x2B50 or cp == 0x2B55 or # Star, Circle
0x2934 <= cp <= 0x2935 or # Arrows
0x25AA <= cp <= 0x25FE or # Geometric Shapes
0x2190 <= cp <= 0x21FF or # Arrows
0x20E3 == cp): # Keycap
i += 1
continue
result.append(text[i])
i += 1
return "".join(result).strip()
def _hex_to_rgb(h):
"""Convert '#RRGGBB' or '#RRGGBBAA' to (R,G,B) or (R,G,B,A)."""
h = h.lstrip("#")
if len(h) == 8:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16))
if len(h) == 6:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
return (128, 128, 128)
def _blend_alpha(fg_hex, bg_rgb=(13, 15, 26)):
"""Blend a hex color with alpha (e.g. '#4A90D930') onto a background."""
c = _hex_to_rgb(fg_hex)
if len(c) == 4:
r, g, b, a = c
af = a / 255.0
return (
int(r * af + bg_rgb[0] * (1 - af)),
int(g * af + bg_rgb[1] * (1 - af)),
int(b * af + bg_rgb[2] * (1 - af)),
)
return c[:3]
def _export_image_pillow(tree, positions, out: str, fmt: str,
scale: float = 2.0, quality: int = 92):
"""
Render the mind map to PNG/JPG/PDF using only Pillow.
Zero system dependencies — works on Windows/macOS/Linux with just
`pip install pillow`.
"""
from PIL import Image, ImageDraw
BG_COLOR = (13, 15, 26) # #0d0f1a
nodes = flatten_tree(tree)
cmap = build_color_map(tree)
minx, miny, maxx, maxy = bounding_box(positions, nodes, pad=80)
W = maxx - minx
H = maxy - miny
# Apply scale
s = scale
img_w, img_h = int(W * s), int(H * s)
img = Image.new("RGB", (img_w, img_h), BG_COLOR)
draw = ImageDraw.Draw(img, "RGBA") # RGBA for alpha blending
def tx(x): return (x - minx) * s
def ty(y): return (y - miny) * s
# ── Draw edges ────────────────────────────────────────────────────────
for node in nodes:
p = positions.get(node["_id"])
if not p or p.get("parent_id") is None:
continue
pp = positions.get(p["parent_id"])
if not pp:
continue
pnode = next((n for n in nodes if n["_id"] == p["parent_id"]), None)
if not pnode:
continue
color_hex = cmap.get(node["_id"], "#888888")
depth = node["_depth"]
# Opacity simulation: blend with background
if depth == 1:
alpha = 0.85
elif depth == 2:
alpha = 0.6
else:
alpha = 0.4
ec = _hex_to_rgb(color_hex)[:3]
edge_color = tuple(int(ec[i] * alpha + BG_COLOR[i] * (1 - alpha)) for i in range(3))
sw = max(1, int((2.5 if depth == 1 else (1.8 if depth == 2 else 1.3)) * s))
# Draw edge as a series of line segments approximating the Bezier
px_s, py_s = tx(pp["x"]), ty(pp["y"])
cx_s, cy_s = tx(p["x"]), ty(p["y"])
pw_s = pnode["_w"] * s / 2
cw_s = node["_w"] * s / 2
if cx_s >= px_s:
x1 = px_s + pw_s
x2 = cx_s - cw_s
else:
x1 = px_s - pw_s
x2 = cx_s + cw_s
# Draw cubic Bezier approximation (matching SVG/HTML edge_path)
# Tension: depth-1 → 0.5, deeper → 0.45
tension = 0.5 if depth == 1 else 0.45
cpx = x1 + (x2 - x1) * tension
steps = 24
pts = []
for t in range(steps + 1):
t_f = t / steps
# Cubic Bezier: P0=(x1,py), P1=(cpx,py), P2=(cpx,cy), P3=(x2,cy)
u = 1 - t_f
bx = u**3 * x1 + 3 * u**2 * t_f * cpx + 3 * u * t_f**2 * cpx + t_f**3 * x2
by = u**3 * py_s + 3 * u**2 * t_f * py_s + 3 * u * t_f**2 * cy_s + t_f**3 * cy_s
pts.append((bx, by))
for i in range(len(pts) - 1):
draw.line([pts[i], pts[i + 1]], fill=edge_color, width=sw)
# ── Draw nodes ────────────────────────────────────────────────────────
# Root gradient colors
ROOT_GRAD_START = (76, 95, 219) # #4c5fdb
ROOT_GRAD_END = (124, 140, 248) # #7c8cf8
for node in nodes:
p = positions.get(node["_id"])
if not p:
continue
depth = node["_depth"]
c = CFG[min(depth, len(CFG) - 1)]
w_raw, h_raw = node["_w"], node["_h"]
w_s, h_s = w_raw * s, h_raw * s
nx = tx(p["x"]) - w_s / 2
ny = ty(p["y"]) - h_s / 2
rx = int(c["rx"] * s)
color_hex = cmap.get(node["_id"], "#888888")
label = node.get("label") or node.get("central", "")
if depth == 0:
# Root: gradient-like fill (approximate with solid blend)
root_color = tuple((ROOT_GRAD_START[i] + ROOT_GRAD_END[i]) // 2 for i in range(3))
# Glow effect: draw a larger, blurred rect behind
glow_pad = 6 * s
glow_color = root_color + (60,)
draw.rounded_rectangle(
[nx - glow_pad, ny - glow_pad, nx + w_s + glow_pad, ny + h_s + glow_pad],
radius=rx + int(glow_pad), fill=glow_color,
)
draw.rounded_rectangle([nx, ny, nx + w_s, ny + h_s], radius=rx, fill=root_color)
text_color = (255, 255, 255)
elif depth == 1:
fill_color = _blend_alpha(color_hex + "30", BG_COLOR)
outline_color = _hex_to_rgb(color_hex)[:3]
ow = max(1, int(2 * s))
draw.rounded_rectangle([nx, ny, nx + w_s, ny + h_s], radius=rx,
fill=fill_color, outline=outline_color, width=ow)
text_color = (255, 255, 255)
elif depth == 2:
fill_color = _blend_alpha(color_hex + "18", BG_COLOR)
outline_color = _blend_alpha(color_hex + "bb", BG_COLOR)
ow = max(1, int(1.5 * s))
draw.rounded_rectangle([nx, ny, nx + w_s, ny + h_s], radius=rx,
fill=fill_color, outline=outline_color, width=ow)
text_color = (224, 228, 240)
else:
fill_color = _blend_alpha(color_hex + "0e", BG_COLOR)
outline_color = _blend_alpha(color_hex + "77", BG_COLOR)
ow = max(1, int(1 * s))
draw.rounded_rectangle([nx, ny, nx + w_s, ny + h_s], radius=rx,
fill=fill_color, outline=outline_color, width=ow)
text_color = (168, 176, 200)
# ── Draw text ─────────────────────────────────────────────────
font_size = int(c["fs"] * s)
is_bold = c["fw"] in ("bold", "600", "700")
font = _get_font(font_size, bold=is_bold)
# Strip emoji for Pillow (system fonts can't render color emoji)
render_label = _strip_emoji_for_pillow(label)
tw, th = _pillow_measure_text(render_label, font)
text_x = tx(p["x"]) - tw / 2
text_y = ty(p["y"]) - th / 2
draw.text((text_x, text_y), render_label, fill=text_color, font=font)
# ── Save ──────────────────────────────────────────────────────────────
if fmt == "jpg":
img = img.convert("RGB")
img.save(out, "JPEG", quality=quality, optimize=True)
elif fmt == "pdf":
# Embed the rendered image into a single-page PDF via Pillow
# Pillow can save PDF directly from an Image object
img = img.convert("RGB")
img.save(out, "PDF", resolution=72.0 * s)
else:
img.save(out, "PNG", optimize=True)
# ─────────────────────────────────────────────────────────────────────────────
# MAIN
# ─────────────────────────────────────────────────────────────────────────────
def main():
args = parse_args()
fmt = args.format.lower()
if args.output is None:
out = default_output(fmt)
else:
out = resolve_output(args.output, fmt)
try:
raw = json.loads(args.data)
except json.JSONDecodeError as e:
print(f"[mindmap] ERROR: Invalid JSON — {e}", file=sys.stderr); sys.exit(1)
try:
tree = build_tree(raw)
except ValueError as e:
print(f"[mindmap] ERROR: {e}", file=sys.stderr); sys.exit(1)
# ── XMind (pure Python, no deps) ────────────────────────────────────
if fmt == "xmind":
open(out, "wb").write(build_xmind(tree, args.title))
print(f"[mindmap] ✅ XMind → {out}"); return
# ── SVG (static, pure Python) ───────────────────────────────────────
if fmt == "svg":
_nid[0] = 0
tree["_depth"] = 0; tree["label"] = tree["central"]
annotate(tree, 0)
positions = compute_layout(tree)
svg_str = render_svg_static(tree, positions, include_xml_header=True)
open(out, "w", encoding="utf-8").write(svg_str)
print(f"[mindmap] ✅ SVG → {out}"); return
# ── PNG / JPG / PDF — auto-select best available backend ────────────
if fmt in ("png", "jpg", "pdf"):
# Backend 1: Playwright (best quality for png/jpg)
if fmt in ("png", "jpg") and _has_playwright():
html_str = render_html(args.title, json.dumps(tree, ensure_ascii=False))
_export_image_playwright(html_str, out, fmt,
scale=args.scale, quality=args.quality)
print(f"[mindmap] ✅ {fmt.upper()} → {out} (via Playwright)"); return
# Backend 2: Pillow (pure Python, works everywhere)
# Auto-install if missing
if not _ensure_pillow():
print("[mindmap] ERROR: Cannot export image — Pillow is required.", file=sys.stderr)
print("[mindmap] Please run: pip install pillow", file=sys.stderr)
sys.exit(1)
_nid[0] = 0
tree["_depth"] = 0; tree["label"] = tree["central"]
annotate(tree, 0)
positions = compute_layout(tree)
_export_image_pillow(tree, positions, out, fmt,
scale=args.scale, quality=args.quality)
print(f"[mindmap] ✅ {fmt.upper()} → {out} (via Pillow)"); return
# ── HTML (default) ──────────────────────────────────────────────────
html = render_html(args.title, json.dumps(tree, ensure_ascii=False))
open(out, "w", encoding="utf-8").write(html)
print(f"[mindmap] ✅ HTML → {out}")
if __name__ == "__main__":
main()
FILE:examples/ai_trends.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>AI 发展趋势</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",
"Hiragino Sans GB","Microsoft YaHei",sans-serif;
background:#0d0f1a;color:#e8eaf0;
height:100vh;overflow:hidden;display:flex;flex-direction:column;
}
header{
padding:8px 16px;background:rgba(255,255,255,.04);
border-bottom:1px solid rgba(255,255,255,.07);
display:flex;flex-direction:column;gap:6px;
flex-shrink:0;user-select:none;
}
.header-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap;}
.header-row.top{justify-content:space-between;}
header h1{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;flex:1;min-width:0;}
.btn-group{
display:flex;align-items:center;gap:2px;
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
border-radius:8px;padding:2px;
}
.btn-group-label{
font-size:10px;color:rgba(255,255,255,.28);padding:0 5px 0 7px;
white-space:nowrap;letter-spacing:.04em;text-transform:uppercase;
}
.btn{
background:transparent;border:1px solid transparent;
color:#c0c4dc;padding:4px 9px;border-radius:6px;font-size:12px;
cursor:pointer;transition:background .12s,color .12s,border-color .12s;
white-space:nowrap;
}
.btn:hover{background:rgba(255,255,255,.1);color:#fff;border-color:rgba(255,255,255,.13);}
.btn:active{background:rgba(255,255,255,.16);}
.btn.exp{
font-size:11px;padding:4px 10px;
color:rgba(180,200,255,.75);
background:rgba(55,85,200,.14);
border-color:rgba(90,130,255,.22);
}
.btn.exp:hover{background:rgba(75,110,230,.3);border-color:rgba(120,160,255,.4);color:#ccd8ff;}
.sep{width:1px;height:16px;background:rgba(255,255,255,.1);margin:0 2px;}
.btn.layout-btn{padding:3px 8px;font-size:11px;color:rgba(255,255,255,.5);}
.btn.layout-btn:hover{color:#e8eaf0;}
.btn.layout-btn.active{background:rgba(124,140,248,.28);border-color:rgba(124,140,248,.55);color:#b4bcff;font-weight:500;}
.btn.undo-btn{font-size:14px;padding:3px 7px;color:rgba(255,255,255,.38);}
.btn.undo-btn:not([disabled]):hover{color:#e8eaf0;}
.btn.undo-btn[disabled]{opacity:.28;cursor:default;}
.btn.undo-btn[disabled]:hover{background:transparent;border-color:transparent;}
.btn.undo-btn{padding:4px 8px;font-size:12px;}
.btn.undo-btn:disabled{opacity:.3;cursor:default;}
.meta{font-size:10px;color:rgba(255,255,255,.22);white-space:nowrap;margin-left:auto;}
#wrap{flex:1;overflow:hidden;position:relative;}
svg{width:100%;height:100%;display:block;}
.nd{cursor:pointer;}
.nd .bg{transition:filter .12s;}
.nd:hover .bg{filter:brightness(1.3);}
.nd.selected .sel-ring{display:block;}
.sel-ring{display:none;pointer-events:none;}
.rh {opacity:0;transition:opacity .15s;cursor:ew-resize;}
.rh-b{opacity:0;transition:opacity .15s;cursor:ns-resize;}
.nd:hover .rh,.nd:hover .rh-b{opacity:1;}
.tog circle{transition:fill .12s;}
.tog:hover circle{opacity:.9;}
.edge{fill:none;}
/* context menu */
#ctx-menu{
position:fixed;display:none;z-index:300;
background:rgba(18,22,38,.98);border:1px solid rgba(255,255,255,.13);
border-radius:9px;padding:5px 0;min-width:165px;
box-shadow:0 8px 32px rgba(0,0,0,.5);font-size:13px;
}
#ctx-menu.open{display:block;}
.ctx-item{padding:7px 16px;cursor:pointer;display:flex;align-items:center;gap:9px;color:#e0e4f0;transition:background .1s;user-select:none;}
.ctx-item:hover{background:rgba(255,255,255,.08);}
.ctx-item.danger{color:#f87171;}
.ctx-item.danger:hover{background:rgba(248,113,113,.1);}
.ctx-sep{height:1px;background:rgba(255,255,255,.08);margin:4px 0;}
.ctx-icon{width:16px;text-align:center;font-size:14px;}
.ctx-colors{padding:6px 12px;display:flex;gap:6px;flex-wrap:wrap;}
.color-dot{width:18px;height:18px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:transform .1s,border-color .1s;}
.color-dot.active{border-color:#fff;transform:scale(1.25);box-shadow:0 0 0 2px rgba(255,255,255,.3);}
.color-dot:hover{transform:scale(1.2);border-color:rgba(255,255,255,.5);}
/* toast */
#toast{
position:fixed;bottom:20px;left:50%;transform:translateX(-50%);
background:rgba(13,15,26,.97);border:1px solid rgba(255,255,255,.15);
border-radius:8px;padding:7px 18px;font-size:13px;
pointer-events:none;opacity:0;transition:opacity .2s;z-index:99;
}
#toast.show{opacity:1;}
</style>
</head>
<body>
<header>
<!-- 第一行:标题 + 导出 -->
<div class="header-row top">
<h1>🧠 AI 发展趋势</h1>
<div class="btn-group">
<span class="btn-group-label">导出</span>
<button class="btn exp" onclick="exportAs('svg')" title="导出 SVG">SVG</button>
<button class="btn exp" onclick="exportAs('png')" title="导出 PNG">PNG</button>
<button class="btn exp" onclick="exportAs('jpg')" title="导出 JPG">JPG</button>
<button class="btn exp" onclick="exportAs('pdf')" title="导出 PDF">PDF</button>
<button class="btn exp" onclick="exportXmind()" title="导出 XMind">XMind</button>
</div>
</div>
<!-- 第二行:视图控制 + 撤销 + 布局 -->
<div class="header-row">
<div class="btn-group">
<span class="btn-group-label">视图</span>
<button class="btn" onclick="resetView()" title="重置视图">⊙</button>
<button class="btn" onclick="expandAll()" title="全部展开">⊞</button>
<button class="btn" onclick="collapseAll()" title="全部折叠">⊟</button>
<button class="btn" onclick="zoomIn()" title="放大">+</button>
<button class="btn" onclick="zoomOut()" title="缩小">-</button>
</div>
<div class="btn-group">
<span class="btn-group-label">历史</span>
<button class="btn undo-btn" id="undo-btn" onclick="undo()" title="撤销 (Ctrl+Z)" disabled>↶</button>
<button class="btn undo-btn" id="redo-btn" onclick="redo()" title="重做 (Ctrl+Y)" disabled>↷</button>
</div>
<div class="btn-group">
<span class="btn-group-label">布局</span>
<button class="btn layout-btn active" id="layout-btn-0" onclick="switchLayout(0)" title="左右均衡">⇆ 左右</button>
<button class="btn layout-btn" id="layout-btn-1" onclick="switchLayout(1)" title="全向辐射">✶ 辐射</button>
<button class="btn layout-btn" id="layout-btn-2" onclick="switchLayout(2)" title="向右树形">➡ 树形</button>
<button class="btn layout-btn" id="layout-btn-3" onclick="switchLayout(3)" title="垂直向下">🌳 垂直</button>
<button class="btn layout-btn" id="layout-btn-4" onclick="switchLayout(4)" title="力导向动画">⚡ 力导向</button>
<button class="btn layout-btn" id="layout-btn-5" onclick="switchLayout(5)" title="时间线">⏩ 时间线</button>
<button class="btn layout-btn" id="layout-btn-6" onclick="switchLayout(6)" title="鱼骨图">🐟 鱼骨</button>
<button class="btn layout-btn" id="layout-btn-7" onclick="switchLayout(7)" title="括弧图">} 括弧</button>
</div>
<span class="meta">右键菜单 · Tab 添加子节点 · Del 删除 · Ctrl+Z 撤销</span>
</div>
</header>
<div id="wrap">
<svg id="svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<linearGradient id="root-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4c5fdb"/>
<stop offset="100%" stop-color="#7c8cf8"/>
</linearGradient>
</defs>
<g id="edges-g"></g>
<g id="nodes-g"></g>
</svg>
</div>
<div id="ctx-menu">
<div class="ctx-item" onclick="ctxAction('add-child')"><span class="ctx-icon">+</span>添加子节点</div>
<div class="ctx-item" onclick="ctxAction('add-sibling')"><span class="ctx-icon">↵</span>添加兄弟节点</div>
<div class="ctx-sep"></div>
<div class="ctx-item" style="font-size:11px;color:rgba(255,255,255,.4);padding:4px 16px;cursor:default;" id="ctx-color-label">更改节点颜色</div>
<div class="ctx-colors" id="ctx-colors"></div>
<div class="ctx-sep"></div>
<div class="ctx-item danger" onclick="ctxAction('delete')"><span class="ctx-icon">🗑</span>删除节点</div>
</div>
<div id="toast"></div>
<script>
const RAW = {"central": "AI 发展趋势", "branches": [{"label": "🤖 大语言模型", "color": "#4A90D9", "children": [{"label": "能力提升", "children": [{"label": "多模态理解", "children": []}, {"label": "代码生成", "children": []}, {"label": "逻辑推理", "children": []}]}, {"label": "开源生态", "children": [{"label": "Llama 系列", "children": []}, {"label": "Mistral", "children": []}, {"label": "Qwen", "children": []}]}, {"label": "模型压缩", "children": []}, {"label": "长上下文", "children": []}]}, {"label": "🤖 AI Agent", "color": "#E86C3A", "children": [{"label": "自主规划", "children": []}, {"label": "工具调用", "children": []}, {"label": "记忆系统", "children": [{"label": "短期记忆", "children": []}, {"label": "长期记忆", "children": []}, {"label": "外部知识库", "children": []}]}, {"label": "多智能体", "children": [{"label": "协作框架", "children": []}, {"label": "角色分工", "children": []}]}]}, {"label": "🛠️ 行业应用", "color": "#27AE60", "children": [{"label": "医疗健康", "children": [{"label": "辅助诊断", "children": []}, {"label": "新药研发", "children": []}]}, {"label": "教育培训", "children": [{"label": "个性化学习", "children": []}, {"label": "智能批改", "children": []}]}, {"label": "金融风控", "children": []}, {"label": "代码开发", "children": []}, {"label": "内容创作", "children": []}]}, {"label": "🔒 安全与治理", "color": "#9B59B6", "children": [{"label": "技术安全", "children": [{"label": "幻觉问题", "children": []}, {"label": "对齐研究", "children": []}, {"label": "可解释性", "children": []}]}, {"label": "数据隐私", "children": [{"label": "联邦学习", "children": []}, {"label": "差分隐私", "children": []}]}, {"label": "监管政策", "children": []}, {"label": "伦理规范", "children": []}]}, {"label": "🌟 基础设施", "color": "#F39C12", "children": [{"label": "算力", "children": [{"label": "GPU 集群", "children": []}, {"label": "专用芯片", "children": []}, {"label": "分布式训练", "children": []}]}, {"label": "训练框架", "children": []}, {"label": "推理优化", "children": []}, {"label": "云服务平台", "children": []}]}]};
const TITLE = "AI 发展趋势";
const SVG_NS = "http://www.w3.org/2000/svg";
const CFG = [
{h:48,fs:16,fw:"700",rx:12,px:32,minW:180},
{h:38,fs:13,fw:"600",rx: 8,px:24,minW:110},
{h:30,fs:12,fw:"400",rx: 6,px:18,minW: 80},
{h:26,fs:11,fw:"400",rx: 5,px:16,minW: 72},
];
const PALETTE=["#4A90D9","#E86C3A","#27AE60","#9B59B6","#E74C3C","#F39C12","#1ABC9C","#E91E63","#00BCD4","#8BC34A"];
const H_GAP=[0,64,48,40], V_GAP=[0,20,12,8];
const MIN_W=60, MIN_H=20, HW=7;
let nodeMap={}, _nid=0, selectedId=null, ctxTargetId=null;
let _pushScheduled=false; // rAF throttle for pushAway
let _undoStack=[], _redoStack=[]; // undo/redo snapshot stacks
const MAX_UNDO=50;
function measureW(text,depth){
const c=CFG[Math.min(depth,CFG.length-1)];
let w=0; for(const ch of String(text)) w+=ch.charCodeAt(0)>127?c.fs*0.92:c.fs*0.58;
return Math.max(c.minW,w+c.px*2);
}
function annotate(node, depth, branchColor){
node._id=node._id||"n"+(++_nid); node._depth=depth;
if(node._collapsed===undefined) node._collapsed=false;
node._pinned=node._pinned||false;
if(node._px===undefined) node._px=null;
if(node._py===undefined) node._py=null;
node._w=node._w||measureW(node.label||node.central||"",depth);
node._h=node._h||CFG[Math.min(depth,CFG.length-1)].h;
// 缓存所属分支的主题色,O(1) 查色,避免 nodeColor 每帧递归
if(depth===0) node._branchColor=null;
else if(depth===1) node._branchColor=node.color||null;
else node._branchColor=branchColor||null;
nodeMap[node._id]=node;
const bc = depth===1 ? (node.color||null) : branchColor||null;
(node.children||[]).forEach(ch=>annotate(ch,depth+1,bc));
(node.branches||[]).forEach(b=>annotate(b,1,null));
}
function visKids(node){return node._collapsed?[]:(node.children||[]);}
function newId(){_nid++;let id="n"+_nid;while(nodeMap[id]){_nid++;id="n"+_nid;}return id;}
let pos={};
let currentLayout=0; // 0=左右均衡 1=辐射 2=向右树 3=垂直树 4=力导向
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 0 — 左右均衡树(默认)
分支均分左右,每侧垂直树形,S曲线+直角折线
══════════════════════════════════════════════════════════════════════════ */
function subtreeH(node){
const vg=V_GAP[Math.min(node._depth,V_GAP.length-1)];
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h,kids.reduce((s,k)=>s+subtreeH(k),0)+vg*(kids.length-1));
}
function layoutSubtree(node,cx,cy,side){
pos[node._id]={x:cx,y:cy,parentId:pos[node._id]?.parentId,side};
const kids=visKids(node); if(!kids.length) return;
const vg=V_GAP[Math.min(node._depth+1,V_GAP.length-1)];
const hg=H_GAP[Math.min(node._depth+1,H_GAP.length-1)];
const maxCW=Math.max(...kids.map(k=>k._w));
const childCX=cx+side*(node._w/2+hg+maxCW/2);
const heights=kids.map(k=>subtreeH(k));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(kids.length-1);
let curY=cy-totalH/2;
kids.forEach((kid,i)=>{
const kcy=curY+heights[i]/2;
pos[kid._id]={x:childCX,y:kcy,parentId:node._id,side};
layoutSubtree(kid,childCX,kcy,side);
curY+=heights[i]+vg;
});
}
function layout0(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:0};
const branches=tree.branches||[]; if(!branches.length) return;
const nRight=Math.ceil(branches.length/2);
function placeSide(brs,side){
if(!brs.length) return;
const maxBW=Math.max(...brs.map(b=>b._w));
const branchCX=side*(tree._w/2+H_GAP[1]+maxBW/2);
const heights=brs.map(b=>subtreeH(b));
const totalH=heights.reduce((a,b)=>a+b,0)+V_GAP[1]*(brs.length-1);
let curY=-totalH/2;
brs.forEach((b,i)=>{
const bcy=curY+heights[i]/2;
pos[b._id]={x:branchCX,y:bcy,parentId:tree._id,side:side};
layoutSubtree(b,branchCX,bcy,side);
curY+=heights[i]+V_GAP[1];
});
}
placeSide(branches.slice(0,nRight),1);
placeSide(branches.slice(nRight),-1);
}
function edgePath0(px,py,pw,cx,cy,cw,depth,side){
/* 左右均衡布局:全程三次贝塞尔,从节点侧边水平切出/切入
控制点在水平中点,产生优雅的 S 形曲线 */
const dx = cx - px;
const s = dx >= 0 ? 1 : -1; // 实际方向
const x1 = px + s * pw/2; // 父节点出口(侧边中心)
const x2 = cx - s * cw/2; // 子节点入口(侧边中心)
// 控制点张力:depth=1 用 0.5(标准 S 曲线),深层略收紧
const t = depth === 1 ? 0.5 : 0.45;
const cpx = x1 + (x2 - x1) * t;
// 节点几乎水平对齐时退化为直线
if(Math.abs(cy - py) < 3) return `Mx1,py Lx2,cy`;
return `Mx1,py Ccpx,py cpx,cy x2,cy`;
}
function edgePath1(px,py,cx,cy,depth){
/* 辐射布局:从父节点中心到子节点中心,沿径向方向平滑贝塞尔
控制点在各自 y 保持,让线条沿水平/垂直方向自然流出 */
const mx = (px+cx)/2, my = (py+cy)/2;
const t = depth === 1 ? 0.5 : 0.42;
const cp1x = px+(cx-px)*t, cp2x = cx-(cx-px)*t;
return `Mpx,py Ccp1x,py cp2x,cy cx,cy`;
}
function edgePath2(px,py,pw,cx,cy,cw,depth){
/* 树形(向右):从父节点右边出发,到子节点左边进入,圆角肘形
保留视觉上的流程感,同时用贝塞尔圆滑转角 */
const x1 = px + pw/2; // 父右边
const x2 = cx - cw/2; // 子左边
const mid = x1 + (x2 - x1) * 0.5;
if(Math.abs(cy - py) < 3) return `Mx1,py Lx2,cy`;
// 三次贝塞尔:水平出 → 水平入,中点弯曲
return `Mx1,py Cmid,py mid,cy x2,cy`;
}
function edgePath3(px,py,pw,cx,cy,cw,depth){
/* 垂直树:中心到中心,控制点保持各自 x,产生垂直 S 曲线 */
const my = (py + cy) / 2;
if(Math.abs(cx - px) < 3) return `Mpx,py Lcx,cy`;
return `Mpx,py Cpx,my cx,my cx,cy`;
}
function edgePath4(px,py,cx,cy){
/* 力导向:点到点平滑贝塞尔,控制点在中点 */
const mx = (px+cx)/2, my = (py+cy)/2;
const dx = cx-px, dy = cy-py, len = Math.sqrt(dx*dx+dy*dy)||1;
const perp = Math.min(len*0.12, 24);
const nx = -dy/len*perp, ny = dx/len*perp;
return `Mpx,py Qmx+nx,my+ny cx,cy`;
}
function drawSpine3(){
// 垂直树不需要装饰线,清除旧环形圈
document.querySelectorAll(".circ-ring").forEach(e => e.remove());
document.getElementById("fishbone-spine")?.remove();
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 1 — 全辐射(圆形散射)
══════════════════════════════════════════════════════════════════════════ */
function layout1(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:1};
const branches=tree.branches||[]; if(!branches.length) return;
function leafCount(node){
const kids=visKids(node);
return kids.length?kids.reduce((s,k)=>s+leafCount(k),0):1;
}
const totalLeaves=branches.reduce((s,b)=>s+leafCount(b),0);
const R1=Math.max(180, tree._w/2+120);
const R2=120;
let angle=-Math.PI/2;
branches.forEach(branch=>{
const frac=leafCount(branch)/totalLeaves;
const span=frac*Math.PI*2;
const mid=angle+span/2;
angle+=span;
const side=Math.cos(mid)>=0?1:-1;
const bx=Math.cos(mid)*R1, by=Math.sin(mid)*R1;
pos[branch._id]={x:bx,y:by,parentId:tree._id,side,angle:mid};
const kids=visKids(branch);
if(!kids.length) return;
const fanSpan=Math.min(span*.8, Math.PI*.6);
const fanStart=mid-fanSpan/2;
kids.forEach((kid,i)=>{
const ka=fanStart+(fanSpan*i)/(Math.max(kids.length-1,1))||mid;
const kR=R1+R2+kid._w/2;
const kx=Math.cos(ka)*kR, ky=Math.sin(ka)*kR;
pos[kid._id]={x:kx,y:ky,parentId:branch._id,side:Math.cos(ka)>=0?1:-1,angle:ka};
const gkids=visKids(kid);
if(!gkids.length) return;
const gFan=Math.min(fanSpan/(kids.length||1)*.9, Math.PI*.3);
gkids.forEach((gk,j)=>{
const ga=ka+(j-(gkids.length-1)/2)*gFan/(Math.max(gkids.length-1,1)||1);
const gR=kR+R2*.7+gk._w/2;
pos[gk._id]={x:Math.cos(ga)*gR,y:Math.sin(ga)*gR,parentId:kid._id,side:Math.cos(ga)>=0?1:-1,angle:ga};
});
});
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 2 — 向右树(Org Chart)
══════════════════════════════════════════════════════════════════════════ */
function subtreeH2(node){
const vg=Math.max(V_GAP[Math.min(node._depth,V_GAP.length-1)],14);
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h,kids.reduce((s,k)=>s+subtreeH2(k),0)+vg*(kids.length-1));
}
function layoutSubtree2(node,lx,cy){
const kids=visKids(node); if(!kids.length) return;
const hg=H_GAP[Math.min(node._depth+1,H_GAP.length-1)]+8;
const vg=Math.max(V_GAP[Math.min(node._depth+1,V_GAP.length-1)],14);
const maxCW=Math.max(...kids.map(k=>k._w));
const childLX=lx+node._w+hg;
const childCX=childLX+maxCW/2;
const heights=kids.map(k=>subtreeH2(k));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(kids.length-1);
let curY=cy-totalH/2;
kids.forEach((kid,i)=>{
const kcy=curY+heights[i]/2;
pos[kid._id]={x:childCX,y:kcy,parentId:node._id,side:1};
layoutSubtree2(kid,childLX,kcy);
curY+=heights[i]+vg;
});
}
function layout2(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:1};
const branches=tree.branches||[]; if(!branches.length) return;
const hg=H_GAP[1]+8;
const vg=Math.max(V_GAP[1],14);
const maxBW=Math.max(...branches.map(b=>b._w));
const branchLX=tree._w/2+hg;
const branchCX=branchLX+maxBW/2;
const heights=branches.map(b=>subtreeH2(b));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(branches.length-1);
let curY=-totalH/2;
branches.forEach((b,i)=>{
const bcy=curY+heights[i]/2;
pos[b._id]={x:branchCX,y:bcy,parentId:tree._id,side:1};
layoutSubtree2(b,branchLX,bcy);
curY+=heights[i]+vg;
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 3 — 垂直树(Vertical Tree / Top-Down)
══════════════════════════════════════════════════════════════════════════ */
function subtreeW3(node){
const hg=20;
const kids=visKids(node);
if(!kids.length) return node._w;
const childrenW=kids.reduce((s,k)=>s+subtreeW3(k),0)+hg*(kids.length-1);
return Math.max(node._w,childrenW);
}
function placeSubtree3(node,cx,top,parentId){
const cy=top+node._h/2;
pos[node._id]={x:cx,y:cy,parentId,side:1};
const kids=visKids(node); if(!kids.length) return;
const V_STEP=80, H_GAP_3=20;
const childTop=top+node._h+V_STEP;
const widths=kids.map(k=>subtreeW3(k));
const totalW=widths.reduce((a,b)=>a+b,0)+H_GAP_3*(kids.length-1);
let curX=cx-totalW/2;
kids.forEach((kid,i)=>{
const kidCX=curX+widths[i]/2;
placeSubtree3(kid,kidCX,childTop,node._id);
curX+=widths[i]+H_GAP_3;
});
}
function layout3(tree){
pos={};
const branches=tree.branches||[];
pos[tree._id]={x:0,y:0,parentId:null,side:1};
if(!branches.length) return;
const V_STEP=80, H_GAP_3=20;
const top1=tree._h/2+V_STEP;
const widths=branches.map(b=>subtreeW3(b));
const totalW=widths.reduce((a,b)=>a+b,0)+H_GAP_3*(branches.length-1);
let curX=-totalW/2;
branches.forEach((branch,i)=>{
const bx=curX+widths[i]/2;
placeSubtree3(branch,bx,top1,tree._id);
curX+=widths[i]+H_GAP_3;
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 4 — 力导向(Force-Directed)
· Coulomb 斥力 + Hooke 弹簧 + Verlet 积分
· 根节点固定在中心,其余节点自由浮动
· 120 帧动画后定格
══════════════════════════════════════════════════════════════════════════ */
let _fdRunning = false;
let _fdTimer = null;
function layout4(tree){
stopFD();
// ── 1. 收集所有节点(全树,不管 collapsed)────────────────────────────
const all = [];
;(function walk(node){
all.push(node);
(node._depth===0 ? (node.branches||[]) : visKids(node)).forEach(walk);
})(tree);
// ── 2. 从树结构建边(不依赖 pos,避免 stale 问题)─────────────────────
const edges = [];
;(function walkE(node){
const kids = node._depth===0 ? (node.branches||[]) : visKids(node);
kids.forEach(kid=>{ edges.push([node._id, kid._id]); walkE(kid); });
})(tree);
// ── 3. 先用 layout0 给一个合理初始骨架,再叠加力导向 ─────────────────
layout0(tree);
// ── 4. 给 pos 里没有的节点(collapsed)补一个随机初始位置 ───────────────
const seed = () => (Math.random()-0.5)*80;
all.forEach(node=>{
if(!pos[node._id]){
// 找父节点位置作为起点
const parentId = (node._depth===0) ? null
: edges.find(([a,b])=>b===node._id)?.[0] ?? null;
const pp = parentId ? pos[parentId] : null;
pos[node._id] = {
x: (pp ? pp.x : 0) + seed(),
y: (pp ? pp.y : 0) + seed(),
parentId, side:1, vx:0, vy:0
};
} else {
pos[node._id].vx = 0;
pos[node._id].vy = 0;
}
});
// ── 5. 力导向迭代 ────────────────────────────────────────────────────
const K_REPEL = 30000;
const K_SPRING = 0.09;
const DAMPING = 0.80;
const MAX_V = 55;
const FRAMES = 130;
function idealLen(depthA, depthB){ return 150 + Math.max(depthA,depthB)*35; }
let frame = 0;
function tick(){
if(!_fdRunning || currentLayout!==4){ _fdRunning=false; return; }
frame++;
const cool = Math.max(0.04, 1 - frame/FRAMES);
const fx={}, fy={};
all.forEach(n=>{ fx[n._id]=0; fy[n._id]=0; });
// Coulomb 斥力(所有节点对)
for(let i=0;i<all.length;i++){
const a=all[i], pa=pos[a._id];
if(!pa) continue;
for(let j=i+1;j<all.length;j++){
const b=all[j], pb=pos[b._id];
if(!pb) continue;
let dx=pb.x-pa.x, dy=pb.y-pa.y;
const dist2=dx*dx+dy*dy||0.01;
const dist=Math.sqrt(dist2);
// 额外排斥:节点尺寸内强推
const minD=(a._w+b._w)*0.5+24;
const f=K_REPEL/dist2*cool;
const push=dist<minD?(minD-dist)*1.2:0;
const ux=dx/dist, uy=dy/dist;
fx[a._id]-=(f+push)*ux; fy[a._id]-=(f+push)*uy;
fx[b._id]+=(f+push)*ux; fy[b._id]+=(f+push)*uy;
}
}
// Hooke 弹簧引力(有边的节点对)
edges.forEach(([aid,bid])=>{
const pa=pos[aid], pb=pos[bid];
if(!pa||!pb) return;
const dx=pb.x-pa.x, dy=pb.y-pa.y;
const dist=Math.sqrt(dx*dx+dy*dy)||0.01;
const na=nodeMap[aid]||{_depth:0}, nb=nodeMap[bid]||{_depth:1};
const target=idealLen(na._depth, nb._depth);
const stretch=(dist-target)*K_SPRING*cool;
const ux=dx/dist, uy=dy/dist;
if(aid!==tree._id){ fx[aid]+=stretch*ux; fy[aid]+=stretch*uy; }
fx[bid]-=stretch*ux; fy[bid]-=stretch*uy;
});
// 弱中心引力(防止整体漂移)
all.forEach(node=>{
if(node._id===tree._id) return;
const p=pos[node._id]; if(!p) return;
fx[node._id]-=p.x*0.006*cool;
fy[node._id]-=p.y*0.006*cool;
});
// 更新速度和位置
all.forEach(node=>{
if(node._id===tree._id) return;
const p=pos[node._id]; if(!p) return;
p.vx=(p.vx+fx[node._id])*DAMPING;
p.vy=(p.vy+fy[node._id])*DAMPING;
const spd=Math.sqrt(p.vx*p.vx+p.vy*p.vy)||1;
if(spd>MAX_V){ p.vx=p.vx/spd*MAX_V; p.vy=p.vy/spd*MAX_V; }
p.x+=p.vx; p.y+=p.vy;
p.side=p.x>=0?1:-1;
});
renderAll(tree);
if(frame<FRAMES){
_fdTimer=requestAnimationFrame(tick);
} else {
_fdRunning=false;
}
}
_fdRunning=true;
frame=0;
_fdTimer=requestAnimationFrame(tick);
}
function stopFD(){
_fdRunning = false;
if(_fdTimer){ cancelAnimationFrame(_fdTimer); _fdTimer=null; }
}
function edgePath4(px,py,cx,cy){
// 力导向用平滑曲线
const mx=(px+cx)/2, my=(py+cy)/2;
const dx=cx-px, dy=cy-py, len=Math.sqrt(dx*dx+dy*dy)||1;
// 控制点:垂直于连线方向偏移,形成弧线
const perp = Math.min(len*0.15, 30);
const nx=-dy/len*perp, ny=dx/len*perp;
return `Mpx,py Qmx+nx,my+ny cx,cy`;
}
function drawSpine3(){
document.querySelectorAll(".circ-ring,.fd-ring").forEach(e=>e.remove());
document.getElementById("fishbone-spine")?.remove();
document.getElementById("timeline-axis")?.remove();
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 5 — 时间线(Timeline / 水平流程)
· 中心节点在最左侧
· 主分支从左到右等间距水平排列
· 子节点垂直向下展开
· 主分支之间有水平时间轴主干线
══════════════════════════════════════════════════════════════════════════ */
function subtreeH5(node){
const vg=12;
const kids=visKids(node); if(!kids.length) return node._h;
return node._h + 60 + kids.reduce((s,k)=>s+k._h+vg,0) - vg;
}
function layout5(tree){
pos={};
const branches=tree.branches||[];
// root at far left
pos[tree._id]={x:0, y:0, parentId:null, side:1};
if(!branches.length) return;
const H_STEP = 220; // horizontal distance between branch columns
const V_TOP = 90; // vertical distance from timeline axis to first child
// Place branches horizontally
branches.forEach((b,i)=>{
const bx = tree._w/2 + H_STEP * (i+1);
pos[b._id]={x:bx, y:0, parentId:tree._id, side:1};
// Children stacked vertically below
const kids=visKids(b); if(!kids.length) return;
let curY = V_TOP;
kids.forEach(kid=>{
pos[kid._id]={x:bx, y:curY, parentId:b._id, side:1};
// Grandchildren further right
const gkids=visKids(kid); if(!gkids.length){ curY+=kid._h+12; return; }
let gy=curY;
gkids.forEach(gk=>{
pos[gk._id]={x:bx+kid._w/2+80+gk._w/2, y:gy, parentId:kid._id, side:1};
gy+=gk._h+8;
});
curY=Math.max(curY+kid._h+12, gy);
});
});
}
function edgePath5(px,py,pw,cx,cy,cw,depth){
if(depth===1){
// Timeline axis: horizontal straight line
const x1=px+pw/2, x2=cx-cw/2;
return `Mx1,py Lx2,cy`;
}
// Branch to children: vertical drop then horizontal
if(Math.abs(cx-px)<3){
// Straight down
const y1=py+20, y2=cy-cw/4;
return `Mpx,y1 Lcx,y2`;
}
// Horizontal bezier for grandchildren
const x1=px+pw/2, x2=cx-cw/2;
const mid=(x1+x2)/2;
if(Math.abs(cy-py)<3) return `Mx1,py Lx2,cy`;
return `Mx1,py Cmid,py mid,cy x2,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 6 — 鱼骨图(Fishbone / Ishikawa)
· 中心节点(鱼头)在右侧
· 水平主干(鱼脊)从右向左延伸
· 主分支交替从上下两侧 45° 斜向伸出(鱼骨)
· 子节点沿鱼骨方向排列
══════════════════════════════════════════════════════════════════════════ */
function layout6(tree){
pos={};
const branches=tree.branches||[];
// Fish head (root) on the right
pos[tree._id]={x:0, y:0, parentId:null, side:1};
if(!branches.length) return;
const SPINE_STEP = 180; // distance between bones along spine
const BONE_LEN = 130; // length of each bone (diagonal)
const SUB_GAP = 36; // gap between sub-nodes along bone
const ANGLE = Math.PI * 0.38; // ~68° from horizontal
branches.forEach((b,i)=>{
const spineX = -(tree._w/2 + 80 + SPINE_STEP * i);
const upDown = (i % 2 === 0) ? -1 : 1; // alternate up/down
const bx = spineX - Math.cos(ANGLE) * BONE_LEN;
const by = upDown * Math.sin(ANGLE) * BONE_LEN;
pos[b._id]={x:bx, y:by, parentId:tree._id, side:-1, _spineX:spineX};
// Children along the bone direction
const kids=visKids(b); if(!kids.length) return;
const dx = Math.cos(ANGLE) * SUB_GAP * upDown * 0;
const dirX = -Math.cos(ANGLE);
const dirY = upDown * Math.sin(ANGLE);
kids.forEach((kid,j)=>{
const dist = SUB_GAP * (j+1) + kid._w/2;
const kx = bx + dirX * dist * 0.3;
const ky = by + dirY * dist;
pos[kid._id]={x:kx, y:ky, parentId:b._id, side:-1};
// Grandchildren
const gkids=visKids(kid); if(!gkids.length) return;
gkids.forEach((gk,gi)=>{
pos[gk._id]={
x: kx - gk._w/2 - kid._w/2 - 30,
y: ky + (gi - (gkids.length-1)/2) * (gk._h + 6),
parentId:kid._id, side:-1
};
});
});
});
}
function edgePath6(px,py,pw,cx,cy,cw,depth,node){
if(depth===1){
// Bone: from spine attachment point to branch node
const spineX = pos[node?._id]?._spineX;
if(spineX !== undefined){
// Draw: spine point → branch node
return `MspineX,py Lcx,cy`;
}
return `Mpx,py Lcx,cy`;
}
// Sub-bones: straight lines
return `Mpx,py Lcx,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 7 — 括弧图(Brace Map / 层级分解)
· 中心节点在最左侧
· 父节点与子节点之间绘制 SVG 大括号 "}"
· 大括号的尖端对准父节点右侧,两端包裹所有子节点
· 强调 整体 → { 部分1, 部分2, ... } 的分解关系
══════════════════════════════════════════════════════════════════════════ */
function subtreeH7(node){
const vg=16;
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h, kids.reduce((s,k)=>s+subtreeH7(k),0)+vg*(kids.length-1));
}
function layout7(tree){
pos={};
pos[tree._id]={x:0, y:0, parentId:null, side:1};
const branches=tree.branches||[]; if(!branches.length) return;
const BRACE_W = 40; // width of the brace symbol area
const H_GAP_7 = 36; // gap between parent right edge and brace
const H_GAP_C = 20; // gap between brace and children left edge
function placeChildren(node, nodeRightX, cy){
const kids=visKids(node); if(!kids.length) return;
const vg=16;
const maxCW = Math.max(...kids.map(k=>k._w));
const childLX = nodeRightX + H_GAP_7 + BRACE_W + H_GAP_C;
const childCX = childLX + maxCW/2;
const heights = kids.map(k=>subtreeH7(k));
const totalH = heights.reduce((a,b)=>a+b,0) + vg*(kids.length-1);
let curY = cy - totalH/2;
kids.forEach((kid,i)=>{
const kcy = curY + heights[i]/2;
pos[kid._id]={x:childCX, y:kcy, parentId:node._id, side:1};
placeChildren(kid, childLX + maxCW/2, kcy);
curY += heights[i] + vg;
});
}
const maxBW = Math.max(...branches.map(b=>b._w));
const branchLX = tree._w/2 + H_GAP_7 + BRACE_W + H_GAP_C;
const branchCX = branchLX + maxBW/2;
const heights = branches.map(b=>subtreeH7(b));
const totalH = heights.reduce((a,b)=>a+b,0) + 16*(branches.length-1);
let curY = -totalH/2;
branches.forEach((b,i)=>{
const bcy = curY + heights[i]/2;
pos[b._id]={x:branchCX, y:bcy, parentId:tree._id, side:1};
placeChildren(b, branchLX + maxBW/2, bcy);
curY += heights[i] + 16;
});
}
function edgePath7(px,py,pw,cx,cy,cw,depth){
/* Brace Map edge: smooth cubic Bezier with a visible "step" shape.
Unlike tree layout's S-curve (which goes directly from parent to child),
the brace path goes: parent → horizontal exit → step down/up → horizontal enter → child
This creates the visual "}" bracket grouping effect.
Uses only C (cubic bezier) commands — no Q or L — for clean anti-aliased rendering.
*/
const x1 = px + pw/2; // parent right edge
const x2 = cx - cw/2; // child left edge
const midX = x1 + (x2 - x1) * 0.42; // vertical transit x
// Same height → simple S-curve
if(Math.abs(cy - py) < 4){
const cp = x1 + (x2 - x1) * 0.5;
return `Mx1,py Ccp,py cp,cy x2,cy`;
}
// Two-segment cubic bezier: parent→midpoint, midpoint→child
// Segment 1: horizontal exit from parent, curve down/up to midX
// Segment 2: from midX, curve horizontally into child
return `Mx1,py CmidX,py midX,py midX,(py+cy)/2 `
+ `CmidX,cy midX,cy x2,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT DISPATCHER
══════════════════════════════════════════════════════════════════════════ */
function layout(tree){
if(currentLayout===4){ layout4(tree); return; }
stopFD();
if(currentLayout===0) layout0(tree);
else if(currentLayout===1) layout1(tree);
else if(currentLayout===2) layout2(tree);
else if(currentLayout===3) layout3(tree);
else if(currentLayout===5) layout5(tree);
else if(currentLayout===6) layout6(tree);
else if(currentLayout===7) layout7(tree);
}
function edgePath(px,py,pw,cx,cy,cw,depth,side){
if(currentLayout===0) return edgePath0(px,py,pw,cx,cy,cw,depth,side);
if(currentLayout===1) return edgePath1(px,py,cx,cy,depth);
if(currentLayout===2) return edgePath2(px,py,pw,cx,cy,cw,depth);
if(currentLayout===3) return edgePath3(px,py,pw,cx,cy,cw,depth);
if(currentLayout===4) return edgePath4(px,py,cx,cy);
if(currentLayout===5) return edgePath5(px,py,pw,cx,cy,cw,depth);
if(currentLayout===6) return edgePath6(px,py,pw,cx,cy,cw,depth);
if(currentLayout===7) return edgePath7(px,py,pw,cx,cy,cw,depth);
return edgePath0(px,py,pw,cx,cy,cw,depth,side);
}
function switchLayout(n){
stopFD();
currentLayout=n;
Object.values(nodeMap).forEach(node=>{node._pinned=false;node._px=null;node._py=null;});
for(let i=0;i<8;i++){
const btn=document.getElementById("layout-btn-"+i);
if(btn) btn.classList.toggle("active",i===n);
}
rebuild();
resetView();
}
function nodeColor(node){
if(node.color) return node.color;
// 使用 annotate 时缓存的分支颜色,O(1) 查找
if(node._branchColor) return node._branchColor;
return "#888";
}
function el(tag,attrs){
const e=document.createElementNS(SVG_NS,tag);
if(attrs) for(const[k,v]of Object.entries(attrs)) e.setAttribute(k,v);
return e;
}
let edgeEls={}, nodeEls={};
function renderAll(tree){
document.getElementById("nodes-g").innerHTML="";
document.getElementById("edges-g").innerHTML="";
edgeEls={}; nodeEls={};
const all=[],q=[tree];
while(q.length){const n=q.shift();all.push(n);(n._depth===0?(n.branches||[]):visKids(n)).forEach(c=>q.push(c));}
const eg=document.getElementById("edges-g");
all.forEach(node=>{
const p=pos[node._id]; if(!p||p.parentId==null) return;
const pp=pos[p.parentId]; if(!pp) return;
const pNode=nodeMap[p.parentId]||tree, color=nodeColor(node), depth=node._depth, side=p.side||1;
// 线宽和透明度随深度自然收细,产生视觉层次感
const sw = depth===1 ? 2.2 : depth===2 ? 1.5 : 1.1;
const so = depth===1 ? 0.80 : depth===2 ? 0.55 : 0.38;
const path=el("path",{class:"edge",stroke:color,
"stroke-width":sw,
"stroke-opacity":so,
"stroke-linecap":"round","stroke-linejoin":"round","data-nid":node._id});
path.setAttribute("d",edgePath(pp.x,pp.y,pNode._w,p.x,p.y,node._w,depth,side,node._id));
if(pNode._collapsed) path.style.display="none";
eg.appendChild(path); edgeEls[node._id]=path;
});
const ng=document.getElementById("nodes-g");
all.forEach(node=>renderNode(node,ng));
if(currentLayout===3) drawSpine3();
applyTransform();
}
function refreshEdgesFor(id){
const p=pos[id]; if(!p) return;
const node=nodeMap[id]||TREE, path=edgeEls[id];
// 更新到父节点的边
if(path&&p.parentId!=null){
const pp=pos[p.parentId],pN=nodeMap[p.parentId]||TREE;
if(pp) path.setAttribute("d",edgePath(pp.x,pp.y,pN._w,p.x,p.y,node._w,node._depth,p.side||1));
}
// 只更新直接子节点的边(不再深度递归),用 visKids 跳过折叠子树
const kids=node._depth===0?(node.branches||[]):visKids(node);
kids.forEach(kid=>{
const cp=pos[kid._id],cp2=edgeEls[kid._id];
if(cp&&cp2) cp2.setAttribute("d",edgePath(p.x,p.y,node._w,cp.x,cp.y,kid._w,kid._depth,cp.side||1,kid._id));
// 继续向下更新(子节点位置没变,但父位置变了,所以子节点的边起点也变了)
refreshEdgesFor(kid._id);
});
}
function renderNode(node,g){
const p=pos[node._id]; if(!p) return;
const depth=node._depth, c=CFG[Math.min(depth,CFG.length-1)];
const w=node._w, h=node._h, color=nodeColor(node);
const label=node.label||node.central||"";
const kids=node.children||node.branches||[];
const grp=el("g",{class:"nd"+(node._id===selectedId?" selected":""),"data-id":node._id,
transform:`translate(p.x-w/2,p.y-h/2)`});
// selection ring
grp.appendChild(el("rect",{class:"sel-ring",x:-3,y:-3,width:w+6,height:h+6,
rx:c.rx+3,fill:"none",stroke:"#7c8cf8","stroke-width":"2","stroke-dasharray":"5 3",opacity:.8}));
// bg
const bg=el("rect",{class:"bg",width:w,height:h,rx:c.rx,ry:c.rx});
if(depth===0){bg.setAttribute("fill","url(#root-grad)");bg.setAttribute("filter","url(#glow)");}
else if(depth===1){bg.setAttribute("fill",color+"30");bg.setAttribute("stroke",color);bg.setAttribute("stroke-width","2");}
else if(depth===2){bg.setAttribute("fill",color+"18");bg.setAttribute("stroke",color+"bb");bg.setAttribute("stroke-width","1.5");}
else{bg.setAttribute("fill",color+"0e");bg.setAttribute("stroke",color+"77");bg.setAttribute("stroke-width","1");}
// label
const tc=depth<=1?"#fff":depth===2?"#e0e4f0":"#a8b0c8";
const txt=el("text",{x:w/2,y:h/2,"dominant-baseline":"central","text-anchor":"middle",
"font-size":c.fs,"font-weight":c.fw,fill:tc,style:"pointer-events:none;user-select:none;"});
txt.textContent=label;
grp.appendChild(bg); grp.appendChild(txt);
// collapse toggle
if(kids.length&&depth>0){
const bx=w-9,by=h-9;
const tg=el("g",{class:"tog","data-id":node._id});
const tc2=el("circle",{cx:bx,cy:by,r:8,fill:color+"33",stroke:color,"stroke-width":"1.2"});
const tt=el("text",{x:bx,y:by,"dominant-baseline":"central","text-anchor":"middle",
"font-size":"11","font-weight":"700",fill:color,style:"pointer-events:none;user-select:none;"});
tt.textContent=node._collapsed?"+":" −";
tg.appendChild(tc2); tg.appendChild(tt);
tg.addEventListener("mousedown",e=>e.stopPropagation());
tg.addEventListener("click",e=>{e.stopPropagation();toggle(node._id);});
grp.appendChild(tg);
}
// resize handles
grp.appendChild(el("rect",{class:"rh",x:w-HW,y:h*.15,width:HW*2,height:h*.7,rx:3,fill:color,"data-resize":"w","data-id":node._id}));
grp.appendChild(el("rect",{class:"rh-b",x:w*.15,y:h-HW,width:w*.7,height:HW*2,rx:3,fill:color,"data-resize":"h","data-id":node._id}));
grp.addEventListener("mousedown",e=>{
if(e.target.closest(".tog")||e.target.dataset.resize) return;
e.stopPropagation(); selectNode(node._id); startNodeDrag(e,node);
});
grp.addEventListener("contextmenu",e=>{
e.preventDefault(); e.stopPropagation(); selectNode(node._id); openCtxMenu(e.clientX,e.clientY,node._id);
});
g.appendChild(grp); nodeEls[node._id]=grp;
}
function patchNodeEl(node){
const grp=nodeEls[node._id]; if(!grp) return;
const p=pos[node._id]; if(!p) return;
const w=node._w,h=node._h;
grp.setAttribute("transform",`translate(p.x-w/2,p.y-h/2)`);
const bg=grp.querySelector(".bg");if(bg){bg.setAttribute("width",w);bg.setAttribute("height",h);}
const t=grp.querySelector("text[dominant-baseline]");if(t){t.setAttribute("x",w/2);t.setAttribute("y",h/2);}
const sr=grp.querySelector(".sel-ring");if(sr){sr.setAttribute("width",w+6);sr.setAttribute("height",h+6);}
const rh=grp.querySelector("[data-resize='w']");if(rh){rh.setAttribute("x",w-HW);rh.setAttribute("y",h*.15);rh.setAttribute("height",h*.7);}
const rb=grp.querySelector("[data-resize='h']");if(rb){rb.setAttribute("x",w*.15);rb.setAttribute("y",h-HW);rb.setAttribute("width",w*.7);}
const tg=grp.querySelector(".tog");
if(tg){const bc=tg.querySelector("circle"),bt=tg.querySelector("text");
if(bc){bc.setAttribute("cx",w-9);bc.setAttribute("cy",h-9);}
if(bt){bt.setAttribute("x",w-9);bt.setAttribute("y",h-9);}}
}
/* ══ SELECTION ══ */
function selectNode(id){
if(selectedId&&nodeEls[selectedId]) nodeEls[selectedId].classList.remove("selected");
selectedId=id;
if(id&&nodeEls[id]) nodeEls[id].classList.add("selected");
}
/* ══ CONTEXT MENU ══ */
function buildColorDots(){
const cont=document.getElementById("ctx-colors"); cont.innerHTML="";
const curNode=nodeMap[ctxTargetId];
const curColor=curNode?.color||null;
PALETTE.forEach(c=>{
const d=document.createElement("div");
d.className="color-dot"+(c===curColor?" active":"");
d.style.background=c; d.title=c;
d.onclick=()=>ctxAction("color",c); cont.appendChild(d);
});
const r=document.createElement("div");
r.className="color-dot"+(curColor===null?" active":"");
r.style.background="rgba(255,255,255,.15)";
r.style.cssText+=";font-size:11px;display:flex;align-items:center;justify-content:center;";
r.title="自动颜色";r.textContent="↺";r.onclick=()=>ctxAction("color",null);
cont.appendChild(r);
}
function openCtxMenu(x,y,id){
ctxTargetId=id; buildColorDots();
// Update color label to show what will be changed
const lbl=document.getElementById("ctx-color-label");
if(lbl){
const n=nodeMap[id];
const depth=n?n._depth:0;
if(depth===0) lbl.textContent="更改根节点颜色";
else if(depth===1) lbl.textContent="更改分支颜色(影响子节点默认色)";
else lbl.textContent="更改此节点颜色";
}
const menu=document.getElementById("ctx-menu"); menu.classList.add("open");
menu.style.left=x+"px"; menu.style.top=y+"px";
requestAnimationFrame(()=>{
const r=menu.getBoundingClientRect();
if(r.right>window.innerWidth) menu.style.left=(x-r.width)+"px";
if(r.bottom>window.innerHeight) menu.style.top=(y-r.height)+"px";
});
}
function closeCtxMenu(){document.getElementById("ctx-menu").classList.remove("open");ctxTargetId=null;}
function ctxAction(action,extra){
// Save target id BEFORE closeCtxMenu() nulls ctxTargetId
const targetId=ctxTargetId;
closeCtxMenu();
const node=nodeMap[targetId]; if(!node&&action!=="delete") return;
if(action==="add-child"){
snapshotForUndo();
const d=Math.min(node._depth+1,CFG.length-1);
const nn={_id:newId(),label:"新节点",children:[],_depth:d,_collapsed:false,_pinned:false,_px:null,_py:null};
nn._w=measureW("新节点",d); nn._h=CFG[d].h; nodeMap[nn._id]=nn;
// Root node uses .branches, all others use .children
if(node._depth===0){
if(!node.branches) node.branches=[];
node.branches.push(nn);
} else {
if(!node.children) node.children=[];
node.children.push(nn);
}
rebuild();
setTimeout(()=>selectNode(nn._id), 30);
return;
}
if(action==="add-sibling"){
snapshotForUndo();
const p=pos[targetId]; if(!p||!p.parentId){showToast("根节点无法添加兄弟节点");return;}
const parent=nodeMap[p.parentId]||TREE;
const arr=parent._depth===0?(parent.branches||[]):(parent.children||[]);
const idx=arr.findIndex(c=>c._id===targetId);
const d=node._depth;
const nn={_id:newId(),label:"新节点",children:[],_depth:d,_collapsed:false,_pinned:false,_px:null,_py:null};
nn._w=measureW("新节点",d); nn._h=CFG[Math.min(d,CFG.length-1)].h; nodeMap[nn._id]=nn;
arr.splice(idx+1,0,nn); rebuild();
setTimeout(()=>selectNode(nn._id), 30);
return;
}
if(action==="delete"){
if(!node){return;} if(node._depth===0){showToast("不能删除根节点");return;}
snapshotForUndo();
const p=pos[targetId]; if(!p||!p.parentId) return;
const parent=nodeMap[p.parentId]||TREE;
const arr=parent._depth===0?(parent.branches||[]):(parent.children||[]);
const idx=arr.findIndex(c=>c._id===targetId);
if(idx>=0) arr.splice(idx,1);
function rm(n){delete nodeMap[n._id];(n.children||[]).forEach(rm);}
rm(node);
if(selectedId===targetId) selectNode(null);
rebuild(); return;
}
if(action==="color"){
snapshotForUndo();
// Set color on the exact node clicked — no walk-up to branch root
if(extra===null) delete node.color; else node.color=extra;
rebuild(); return;
}
}
// Use mousedown (not click) to close menu so it doesn't race with menu item onclick
document.addEventListener("mousedown",e=>{
const menu=document.getElementById("ctx-menu");
if(menu.classList.contains("open")&&!menu.contains(e.target)) closeCtxMenu();
});
document.addEventListener("contextmenu",e=>{
if(["wrap","svg","edges-g","nodes-g"].includes(e.target.id)||(e.target.tagName==="svg")||(e.target.parentElement&&e.target.parentElement.id==="edges-g"))
e.preventDefault();
});
/* ══ UNDO / REDO ══════════════════════════════════════════════════════════
操作前调用 snapshotForUndo(),将当前树结构序列化压入撤销栈。
Ctrl+Z 弹出并恢复,Ctrl+Y/Ctrl+Shift+Z 重做。
════════════════════════════════════════════════════════════════════════ */
function _treeSnapshot(){
// 序列化当前树(含颜色、折叠状态、位置)
function snap(node){
const out={label:node.label,central:node.central,color:node.color,
_collapsed:node._collapsed,_pinned:node._pinned,_px:node._px,_py:node._py,
_w:node._w,_h:node._h};
if((node.children||[]).length) out.children=(node.children||[]).map(snap);
if((node.branches||[]).length) out.branches=(node.branches||[]).map(snap);
return out;
}
return JSON.stringify(snap(TREE));
}
function _restoreSnapshot(json){
const saved=JSON.parse(json);
function restore(live,saved){
live.label=saved.label; live.central=saved.central;
if(saved.color) live.color=saved.color; else delete live.color;
live._collapsed=saved._collapsed||false;
live._pinned=saved._pinned||false;
live._px=saved._px??null; live._py=saved._py??null;
live._w=saved._w||null; live._h=saved._h||null;
// Rebuild children array from saved data
if(saved.children){
live.children=(saved.children).map(sc=>{
const n={label:sc.label||"",children:[],branches:[]};
restore(n,sc); return n;
});
} else { live.children=[]; }
if(saved.branches){
live.branches=(saved.branches).map(sb=>{
const n={label:sb.label||"",children:[],branches:[]};
restore(n,sb); return n;
});
}
}
restore(TREE,saved);
// 清理所有可能持有旧节点引用的状态,防止 stale reference crash
activeOp = null;
wrap.style.cursor = "";
selectNode(null);
ctxTargetId = null;
_pushScheduled = false;
// 关键修复:_nid 重置为 0 后,必须清除 TREE._id,否则 TREE 保留旧 _id(如 "n1"),
// 而 annotate 从 n1 开始分配,导致第一个分支也拿到 "n1",产生 ID 碰撞,
// nodeMap["n1"] 被分支覆盖,TREE 从 nodeMap 消失,渲染完全混乱。
delete TREE._id;
_nid=0; nodeMap={};
annotate(TREE,0,null);
rebuild();
}
function snapshotForUndo(){
_undoStack.push(_treeSnapshot());
if(_undoStack.length>MAX_UNDO) _undoStack.shift();
_redoStack=[]; // new action clears redo
}
function undo(){
if(!_undoStack.length){ showToast("没有可撤销的操作",1600); return; }
_redoStack.push(_treeSnapshot());
_restoreSnapshot(_undoStack.pop());
showToast("↩ 已撤销",1400);
}
function redo(){
if(!_redoStack.length){ showToast("没有可重做的操作",1600); return; }
_undoStack.push(_treeSnapshot());
_restoreSnapshot(_redoStack.pop());
showToast("↪ 已重做",1400);
}
/* ══ KEYBOARD ══ */
document.addEventListener("keydown",e=>{
// Undo / Redo
if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&e.key==="z"){e.preventDefault();undo();return;}
if((e.ctrlKey||e.metaKey)&&(e.key==="y"||(e.shiftKey&&e.key==="z"))){e.preventDefault();redo();return;}
if((e.key==="Delete"||e.key==="Backspace")&&selectedId){
e.preventDefault();
ctxTargetId=selectedId; ctxAction("delete"); return;
}
if(e.key==="Tab"&&selectedId){
e.preventDefault();
ctxTargetId=selectedId; ctxAction("add-child"); return;
}
if(e.key==="Escape"){selectNode(null);closeCtxMenu();}
});
/* ══ DRAG REPULSION ══════════════════════════════════════════════════════
链式传播算法:
1. 以被拖动节点为压力源,计算每个节点到压力源的距离
2. 按距离从近到远排序,依次推开——近的节点先让位,压力向外传播
3. 推开时近压力源的节点固定(已被推过),只推远端节点
4. 多轮迭代直到全局无重叠,避免振荡
════════════════════════════════════════════════════════════════════════ */
const DRAG_PAD = 10;
const MAX_ITER = 15;
function pushAway(draggedId){
const allIds = Object.keys(pos);
if(allIds.length < 2) return;
// 预计算半尺寸
const hw = {}, hh = {};
allIds.forEach(id => {
const n = nodeMap[id] || TREE;
hw[id] = n._w/2 + DRAG_PAD/2;
hh[id] = n._h/2 + DRAG_PAD/2;
});
const dp = pos[draggedId];
for(let iter = 0; iter < MAX_ITER; iter++){
let anyOverlap = false;
// 按到拖动节点的距离从近到远排序,让压力从内向外传播
const sorted = allIds.slice().sort((a, b) => {
const pa = pos[a], pb = pos[b];
const da = (pa.x-dp.x)**2 + (pa.y-dp.y)**2;
const db = (pb.x-dp.x)**2 + (pb.y-dp.y)**2;
return da - db;
});
for(let i = 0; i < sorted.length; i++){
for(let j = i+1; j < sorted.length; j++){
const ai = sorted[i], aj = sorted[j]; // ai 比 aj 更靠近拖动节点
const pi = pos[ai], pj = pos[aj];
if(!pi || !pj) continue;
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const overlapX = (hw[ai] + hw[aj]) - Math.abs(dx);
const overlapY = (hh[ai] + hh[aj]) - Math.abs(dy);
if(overlapX <= 0 || overlapY <= 0) continue;
anyOverlap = true;
// 关键:ai 更靠近压力源(已被处理过),固定 ai 只推 aj
// 压力单向向外传播,不会产生振荡
if(overlapX <= overlapY){
pj.x += overlapX * (dx >= 0 ? 1 : -1);
} else {
pj.y += overlapY * (dy >= 0 ? 1 : -1);
}
}
}
if(!anyOverlap) break;
}
// 同步视觉、side 和 pinned 坐标
allIds.forEach(id => {
const p = pos[id]; if(!p) return;
const n = nodeMap[id] || TREE;
// 更新 side:始终以父节点为参照
const parentP = p.parentId ? pos[p.parentId] : null;
if(parentP) p.side = p.x >= parentP.x ? 1 : -1;
if(n._pinned){ n._px = p.x; n._py = p.y; }
patchNodeEl(n);
refreshEdgesFor(id);
});
}
/* ══ GLOBAL SEPARATION ═══════════════════════════════════════════════════
布局完成后对所有节点做一次全局分离,确保不重叠。
与 pushAway 的区别:没有固定压力源,每对重叠节点各自向外移动一半,
适合初始布局、切换布局、添加/删除节点后的全局整理。
════════════════════════════════════════════════════════════════════════ */
function separateAll(){
const allIds = Object.keys(pos);
if(allIds.length < 2) return;
const hw = {}, hh = {};
allIds.forEach(id => {
const n = nodeMap[id] || TREE;
hw[id] = n._w/2 + DRAG_PAD/2;
hh[id] = n._h/2 + DRAG_PAD/2;
});
// 预处理:给完全重合的节点施加微小扰动,防止对称死锁
for(let i = 0; i < allIds.length; i++){
for(let j = i+1; j < allIds.length; j++){
const pi = pos[allIds[i]], pj = pos[allIds[j]];
if(!pi||!pj) continue;
if(Math.abs(pi.x-pj.x)<0.1 && Math.abs(pi.y-pj.y)<0.1){
// 按索引差给一个确定性的角度扰动,避免随机性
const angle = (j - i) * 2.399; // 黄金角,均匀分布
pj.x += Math.cos(angle) * 0.5;
pj.y += Math.sin(angle) * 0.5;
}
}
}
const SEP_ITER = allIds.length * 4; // 实测:n*4 覆盖 99% 的实际场景,50节点以内 <10ms
for(let iter = 0; iter < SEP_ITER; iter++){
let anyOverlap = false;
for(let i = 0; i < allIds.length; i++){
for(let j = i+1; j < allIds.length; j++){
const ai = allIds[i], aj = allIds[j];
const pi = pos[ai], pj = pos[aj];
if(!pi || !pj) continue;
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const overlapX = (hw[ai] + hw[aj]) - Math.abs(dx);
const overlapY = (hh[ai] + hh[aj]) - Math.abs(dy);
if(overlapX <= 0 || overlapY <= 0) continue;
anyOverlap = true;
const fixI = (nodeMap[ai]||TREE)._depth === 0;
const fixJ = (nodeMap[aj]||TREE)._depth === 0;
if(overlapX <= overlapY){
const push = overlapX * (dx >= 0 ? 1 : -1);
if(!fixI && !fixJ){ pi.x -= push*0.5; pj.x += push*0.5; }
else if(fixI) pj.x += push;
else pi.x -= push;
} else {
const push = overlapY * (dy >= 0 ? 1 : -1);
if(!fixI && !fixJ){ pi.y -= push*0.5; pj.y += push*0.5; }
else if(fixI) pj.y += push;
else pi.y -= push;
}
}
}
if(!anyOverlap) break;
}
// 同步 side / pinned 坐标
allIds.forEach(id => {
const p = pos[id]; if(!p) return;
const n = nodeMap[id] || TREE;
const parentP = p.parentId ? pos[p.parentId] : null;
if(parentP) p.side = p.x >= parentP.x ? 1 : -1;
if(n._pinned){ n._px = p.x; n._py = p.y; }
});
}
/* ══ INTERACTION ══ */
let activeOp=null, T={x:0,y:0,s:1};
function applyTransform(){
const t=`translate(T.x,T.y) scale(T.s)`;
document.getElementById("edges-g").setAttribute("transform",t);
document.getElementById("nodes-g").setAttribute("transform",t);
}
function svgXY(cx,cy){return{x:(cx-T.x)/T.s,y:(cy-T.y)/T.s};}
function startNodeDrag(e,node){const sv=svgXY(e.clientX,e.clientY);activeOp={type:"nodedrag",node,ox:sv.x-pos[node._id].x,oy:sv.y-pos[node._id].y,moved:false};}
function startResizeW(e,node){e.stopPropagation();const sv=svgXY(e.clientX,e.clientY);activeOp={type:"rw",node,sx:sv.x,sw:node._w};}
function startResizeH(e,node){e.stopPropagation();const sv=svgXY(e.clientX,e.clientY);activeOp={type:"rh",node,sy:sv.y,sh:node._h};}
window.addEventListener("mousemove",e=>{
if(!activeOp) return;
if(activeOp.type==="canvas"){T.x=e.clientX-activeOp.sx;T.y=e.clientY-activeOp.sy;applyTransform();return;}
if(activeOp.type==="nodedrag"){
const sv=svgXY(e.clientX,e.clientY);
if(!activeOp.moved&&Math.hypot(sv.x-pos[activeOp.node._id].x-activeOp.ox,sv.y-pos[activeOp.node._id].y-activeOp.oy)<2) return;
activeOp.moved=true;
const node=activeOp.node;
node._pinned=true; node._px=sv.x-activeOp.ox; node._py=sv.y-activeOp.oy;
pos[node._id].x=node._px; pos[node._id].y=node._py;
// 实时更新 side:节点在父节点哪侧由实际坐标决定
const _pp=pos[pos[node._id].parentId]; if(_pp) pos[node._id].side=pos[node._id].x>=_pp.x?1:-1;
patchNodeEl(node); refreshEdgesFor(node._id);
// rAF throttle: 每帧最多执行一次 pushAway,避免高频 mousemove 掉帧
if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(node._id);});}
return;
}
if(activeOp.type==="rw"){const sv=svgXY(e.clientX,e.clientY);activeOp.node._w=Math.max(MIN_W,activeOp.sw+(sv.x-activeOp.sx));patchNodeEl(activeOp.node);refreshEdgesFor(activeOp.node._id);if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(activeOp.node._id);});}return;}
if(activeOp.type==="rh"){const sv=svgXY(e.clientX,e.clientY);activeOp.node._h=Math.max(MIN_H,activeOp.sh+(sv.y-activeOp.sy));patchNodeEl(activeOp.node);refreshEdgesFor(activeOp.node._id);if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(activeOp.node._id);});}return;}
});
window.addEventListener("mouseup",()=>{
if(!activeOp) return;
if(activeOp.type==="nodedrag"){
if(!activeOp.moved){
const node=activeOp.node,kids=node.children||node.branches||[];
if(kids.length&&node._depth>0) toggle(node._id);
} else {
snapshotForUndo(); // 拖动结束后保存快照
}
}
if(activeOp.type==="rw"||activeOp.type==="rh") snapshotForUndo();
activeOp=null; wrap.style.cursor="";
});
const wrap=document.getElementById("wrap");
wrap.addEventListener("mousedown",e=>{
if(activeOp) return;
if(e.target===e.currentTarget||e.target.tagName==="svg"||["edges-g","nodes-g"].includes(e.target.id))
{selectNode(null);closeCtxMenu();}
activeOp={type:"canvas",sx:e.clientX-T.x,sy:e.clientY-T.y}; wrap.style.cursor="grabbing";
});
document.getElementById("nodes-g").addEventListener("mousedown",e=>{
const rt=e.target.dataset.resize,nid=e.target.dataset.id; if(!rt||!nid) return;
e.stopPropagation(); const node=nodeMap[nid]; if(!node) return;
if(rt==="w")startResizeW(e,node); if(rt==="h")startResizeH(e,node);
});
wrap.addEventListener("wheel",e=>{
e.preventDefault();
const r=wrap.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
const f=e.deltaY<0?1.11:0.9, ns=Math.min(Math.max(T.s*f,0.1),6);
T.x=mx-(ns/T.s)*(mx-T.x); T.y=my-(ns/T.s)*(my-T.y); T.s=ns; applyTransform();
},{passive:false});
let tDrag=null;
wrap.addEventListener("touchstart",e=>{if(e.touches.length===1)tDrag={sx:e.touches[0].clientX-T.x,sy:e.touches[0].clientY-T.y};},{passive:true});
wrap.addEventListener("touchmove",e=>{if(tDrag&&e.touches.length===1){T.x=e.touches[0].clientX-tDrag.sx;T.y=e.touches[0].clientY-tDrag.sy;applyTransform();}},{passive:true});
wrap.addEventListener("touchend",()=>tDrag=null);
/* ══ COLLAPSE ══ */
function toggle(id){const n=nodeMap[id];if(!n)return;n._collapsed=!n._collapsed;rebuild();}
function expandAll(){Object.values(nodeMap).forEach(n=>n._collapsed=false);rebuild();}
function collapseAll(){Object.values(nodeMap).forEach(n=>{if(n._depth>=1)n._collapsed=true;});rebuild();}
function rebuild(){
layout(TREE);
separateAll(); // 布局后全局分离,确保不重叠
renderAll(TREE);
}
function resetView(){T={x:wrap.clientWidth/2,y:wrap.clientHeight/2,s:1};applyTransform();}
function zoomIn(){T.s=Math.min(T.s*1.2,6);applyTransform();}
function zoomOut(){T.s=Math.max(T.s/1.2,0.1);applyTransform();}
/* ══ TOAST ══ */
function showToast(msg,dur=2400){const t=document.getElementById("toast");t.textContent=msg;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),dur);}
/* ══ EXPORT ══ */
function getBounds(){
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
[...Object.values(nodeMap),TREE].forEach(n=>{
const p=pos[n._id];if(!p)return;
x0=Math.min(x0,p.x-n._w/2);x1=Math.max(x1,p.x+n._w/2);
y0=Math.min(y0,p.y-n._h/2);y1=Math.max(y1,p.y+n._h/2);
});
const pad=60; return{x0:x0-pad,y0:y0-pad,w:x1-x0+pad*2,h:y1-y0+pad*2};
}
function buildExportSVG(){
const b=getBounds();
const clone=document.getElementById("svg").cloneNode(true);
clone.querySelectorAll(".rh,.rh-b,.tog,.sel-ring").forEach(e=>e.remove());
clone.querySelectorAll(".nd").forEach(g=>g.classList.remove("selected"));
clone.querySelectorAll("#edges-g,#nodes-g").forEach(g=>g.removeAttribute("transform"));
clone.setAttribute("viewBox",`b.x0 b.y0 b.w b.h`);
clone.setAttribute("width",Math.round(b.w)); clone.setAttribute("height",Math.round(b.h));
clone.style.cssText="";
const bg=document.createElementNS(SVG_NS,"rect");
bg.setAttribute("x",b.x0);bg.setAttribute("y",b.y0);bg.setAttribute("width",b.w);bg.setAttribute("height",b.h);bg.setAttribute("fill","#0d0f1a");
clone.insertBefore(bg,clone.firstChild);
const st=document.createElementNS(SVG_NS,"style");
st.textContent='text{font-family:-apple-system,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;}';
clone.insertBefore(st,clone.firstChild);
return{svgEl:clone,b};
}
function dlBlob(blob,name){
const url=URL.createObjectURL(blob),a=document.createElement("a");
a.href=url;a.download=name;document.body.appendChild(a);a.click();
setTimeout(()=>{document.body.removeChild(a);URL.revokeObjectURL(url);},300);
}
function exportAs(fmt){
showToast("正在导出 "+fmt.toUpperCase()+" …");
const{svgEl,b}=buildExportSVG();
const svgStr=new XMLSerializer().serializeToString(svgEl);
const safe=TITLE.replace(/[\\/:*?"<>|]/g,"_");
if(fmt==="svg"){dlBlob(new Blob([svgStr],{type:"image/svg+xml"}),safe+".svg");return;}
const sc=fmt==="jpg"?2:2.5;
const canvas=document.createElement("canvas"); canvas.width=b.w*sc; canvas.height=b.h*sc;
const ctx=canvas.getContext("2d");
if(fmt==="jpg"){ctx.fillStyle="#0d0f1a";ctx.fillRect(0,0,canvas.width,canvas.height);}
const img=new Image();
const bUrl=URL.createObjectURL(new Blob([svgStr],{type:"image/svg+xml"}));
img.onload=()=>{
ctx.drawImage(img,0,0,canvas.width,canvas.height); URL.revokeObjectURL(bUrl);
if(fmt==="png") canvas.toBlob(bl=>dlBlob(bl,safe+".png"),"image/png");
else if(fmt==="jpg") canvas.toBlob(bl=>dlBlob(bl,safe+".jpg"),"image/jpeg",0.93);
else if(fmt==="pdf") makePDF(canvas,b,safe);
};
img.src=bUrl;
}
function makePDF(canvas,b,safe){
const jData=canvas.toDataURL("image/jpeg",0.92).split(",")[1];
const jBytes=Uint8Array.from(atob(jData),c=>c.charCodeAt(0));
const W=Math.round(b.w),H=Math.round(b.h);
const enc=new TextEncoder();
function str(s){return enc.encode(s);}
const stream=`q W 0 0 H 0 0 cm /Im1 Do Q`;
const objs=[str("%PDF-1.4\n"),str("1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n"),
str(`2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n`),
str(`3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 W H]/Contents 4 0 R/Resources<</XObject<</Im1 5 0 R>>>>>>\nendobj\n`),
str(`4 0 obj\n<</Length stream.length>>\nstream\nstream\nendstream\nendobj\n`),
str(`5 0 obj\n<</Type/XObject/Subtype/Image/Width canvas.width/Height canvas.height/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/DCTDecode/Length jBytes.length>>\nstream\n`),
jBytes,str("\nendstream\nendobj\n"),
str("xref\n0 6\n0000000000 65535 f \ntrailer\n<</Size 6/Root 1 0 R>>\nstartxref\n0\n%%EOF\n")];
const total=objs.reduce((s,o)=>s+o.length,0); const buf=new Uint8Array(total); let off=0;
for(const o of objs){buf.set(o,off);off+=o.length;}
dlBlob(new Blob([buf],{type:"application/pdf"}),safe+".pdf");
}
/* ══ XMIND ══ */
function exportXmind(){
showToast("正在生成 XMind …");
// ── helpers ────────────────────────────────────────────────────────────
function uid(){ return crypto.randomUUID().replace(/-/g,"").slice(0,26); }
function xe(s){ return String(s).replace(/&/g,"&").replace(/</g,"<")
.replace(/>/g,">").replace(/"/g,"""); }
// ── content.json (XMind 2020+) ────────────────────────────────────────
function xnJson(node){
const kids=(node.branches||[]).concat(node.children||[]);
const o={id:uid(),class:"topic",title:node.label||node.central||""};
if(kids.length) o.children={attached:kids.map(xnJson)};
if(node.color) o.style={id:uid(),properties:{
"line-color":node.color,"background-color":node.color+"33",
"border-line-color":node.color,"line-width":"2pt",
"shape-class":"org.xmind.topicShape.roundedRect"}};
return o;
}
const rootJson=xnJson(TREE);
rootJson.structureClass="org.xmind.ui.map.unbalanced";
const contentJson=[{id:uid(),class:"sheet",title:TITLE,rootTopic:rootJson,theme:{},extensions:[]}];
// ── content.xml (XMind 8) ─────────────────────────────────────────────
function xnXml(node, depth){
const kids=(node.branches||[]).concat(node.children||[]);
const label=node.label||node.central||"";
const ind=" ".repeat(depth);
let s=`ind<topic id="uid()"`;
if(depth===0) s+=' structure-class="org.xmind.ui.map.unbalanced"';
s+=`>\nind <title>xe(label)</title>`;
if(kids.length){
s+=`\nind <children>\nind <topics type="attached">`;
for(const c of kids) s+="\n"+xnXml(c,depth+3);
s+=`\nind </topics>\nind </children>`;
}
s+=`\nind</topic>`;
return s;
}
const sheetId=uid();
const xmlRoot=xnXml(TREE,0);
const contentXml=`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n`+
`<xmap-content xmlns="urn:xmind:xmap:xmlns:content:2.0"\n`+
` xmlns:fo="http://www.w3.org/1999/XSL/Format"\n`+
` xmlns:svg="http://www.w3.org/2000/svg"\n`+
` xmlns:xhtml="http://www.w3.org/1999/xhtml"\n`+
` xmlns:xlink="http://www.w3.org/1999/xlink"\n`+
` version="2.0">\n`+
` <sheet id="sheetId">\n`+
xmlRoot+"\n"+
` <title>xe(TITLE)</title>\n`+
` </sheet>\n`+
`</xmap-content>`;
const stylesXml=`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n`+
`<xmap-styles xmlns="urn:xmind:xmap:xmlns:style:2.0" version="2.0"></xmap-styles>`;
// ── metadata & manifest ────────────────────────────────────────────────
const meta={modifier:"",created:new Date().toISOString().slice(0,19)+".000+0000",
creator:{name:"OpenClaw MindMap",version:"5.0",platform:""}};
const mf={"file-entries":{
"content.json":{"media-type":"application/json"},
"content.xml": {"media-type":"text/xml"},
"styles.xml": {"media-type":"text/xml"},
"metadata.json":{"media-type":"application/json"},
"manifest.json":{"media-type":"application/json"}}};
// ── ZIP builder ────────────────────────────────────────────────────────
function u16(v){const b=new Uint8Array(2);new DataView(b.buffer).setUint16(0,v,true);return b;}
function u32(v){const b=new Uint8Array(4);new DataView(b.buffer).setUint32(0,v,true);return b;}
function crc32(d){
if(!crc32.t){crc32.t=new Uint32Array(256);for(let i=0;i<256;i++){let c=i;for(let j=0;j<8;j++)c=c&1?0xEDB88320^(c>>>1):c>>>1;crc32.t[i]=c;}}
let c=0xFFFFFFFF;for(const b of d)c=crc32.t[(c^b)&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
}
function cat(...a){const t=a.reduce((s,x)=>s+x.length,0),o=new Uint8Array(t);let p=0;for(const x of a){o.set(x,p);p+=x.length;}return o;}
const enc=new TextEncoder();
const files=[
["manifest.json", enc.encode(JSON.stringify(mf,null,2))],
["content.json", enc.encode(JSON.stringify(contentJson,null,2))],
["content.xml", enc.encode(contentXml)],
["styles.xml", enc.encode(stylesXml)],
["metadata.json", enc.encode(JSON.stringify(meta,null,2))],
];
const lParts=[],cds=[];let dataOff=0;
for(const[name,data]of files){
const nb=enc.encode(name),crc=crc32(data),sz=data.length;
const lh=cat(new Uint8Array([0x50,0x4B,0x03,0x04]),u16(20),u16(0),u16(0),u16(0),u16(0),
u32(crc),u32(sz),u32(sz),u16(nb.length),u16(0),nb);
const cd=cat(new Uint8Array([0x50,0x4B,0x01,0x02]),u16(20),u16(20),u16(0),u16(0),u16(0),u16(0),
u32(crc),u32(sz),u32(sz),u16(nb.length),u16(0),u16(0),u16(0),u16(0),u32(0),u32(dataOff),nb);
lParts.push(lh,data);cds.push(cd);dataOff+=lh.length+sz;
}
const cdBytes=cat(...cds);
const eocd=cat(new Uint8Array([0x50,0x4B,0x05,0x06]),u16(0),u16(0),
u16(files.length),u16(files.length),u32(cdBytes.length),u32(dataOff),u16(0));
dlBlob(new Blob([cat(...lParts,cdBytes,eocd)],{type:"application/octet-stream"}),
TITLE.replace(/[\\/:*?"<>|]/g,"_")+".xmind");
}
/* ══ BOOT ══ */
RAW._depth=0; RAW.label=RAW.central;
annotate(RAW,0);
const TREE=RAW;
layout(TREE);
separateAll(); // 初始布局后分离
renderAll(TREE);
resetView();
</script>
</body>
</html>
FILE:examples/ai_trends.json
{
"central": "AI 发展趋势",
"branches": [
{
"label": "大语言模型",
"color": "#4A90D9",
"children": [
{"label": "能力提升", "children": ["多模态理解", "代码生成", "逻辑推理"]},
{"label": "开源生态", "children": ["Llama 系列", "Mistral", "Qwen"]},
"模型压缩",
"长上下文"
]
},
{
"label": "AI Agent",
"color": "#E86C3A",
"children": [
"自主规划",
"工具调用",
{"label": "记忆系统", "children": ["短期记忆", "长期记忆", "外部知识库"]},
{"label": "多智能体", "children": ["协作框架", "角色分工"]}
]
},
{
"label": "行业应用",
"color": "#27AE60",
"children": [
{"label": "医疗健康", "children": ["辅助诊断", "新药研发"]},
{"label": "教育培训", "children": ["个性化学习", "智能批改"]},
"金融风控",
"代码开发",
"内容创作"
]
},
{
"label": "安全与治理",
"color": "#9B59B6",
"children": [
{"label": "技术安全", "children": ["幻觉问题", "对齐研究", "可解释性"]},
{"label": "数据隐私", "children": ["联邦学习", "差分隐私"]},
"监管政策",
"伦理规范"
]
},
{
"label": "基础设施",
"color": "#F39C12",
"children": [
{"label": "算力", "children": ["GPU 集群", "专用芯片", "分布式训练"]},
"训练框架",
"推理优化",
"云服务平台"
]
}
]
}
FILE:examples/product_launch.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>产品发布 v2.0</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",
"Hiragino Sans GB","Microsoft YaHei",sans-serif;
background:#0d0f1a;color:#e8eaf0;
height:100vh;overflow:hidden;display:flex;flex-direction:column;
}
header{
padding:8px 16px;background:rgba(255,255,255,.04);
border-bottom:1px solid rgba(255,255,255,.07);
display:flex;flex-direction:column;gap:6px;
flex-shrink:0;user-select:none;
}
.header-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap;}
.header-row.top{justify-content:space-between;}
header h1{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;flex:1;min-width:0;}
.btn-group{
display:flex;align-items:center;gap:2px;
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
border-radius:8px;padding:2px;
}
.btn-group-label{
font-size:10px;color:rgba(255,255,255,.28);padding:0 5px 0 7px;
white-space:nowrap;letter-spacing:.04em;text-transform:uppercase;
}
.btn{
background:transparent;border:1px solid transparent;
color:#c0c4dc;padding:4px 9px;border-radius:6px;font-size:12px;
cursor:pointer;transition:background .12s,color .12s,border-color .12s;
white-space:nowrap;
}
.btn:hover{background:rgba(255,255,255,.1);color:#fff;border-color:rgba(255,255,255,.13);}
.btn:active{background:rgba(255,255,255,.16);}
.btn.exp{
font-size:11px;padding:4px 10px;
color:rgba(180,200,255,.75);
background:rgba(55,85,200,.14);
border-color:rgba(90,130,255,.22);
}
.btn.exp:hover{background:rgba(75,110,230,.3);border-color:rgba(120,160,255,.4);color:#ccd8ff;}
.sep{width:1px;height:16px;background:rgba(255,255,255,.1);margin:0 2px;}
.btn.layout-btn{padding:3px 8px;font-size:11px;color:rgba(255,255,255,.5);}
.btn.layout-btn:hover{color:#e8eaf0;}
.btn.layout-btn.active{background:rgba(124,140,248,.28);border-color:rgba(124,140,248,.55);color:#b4bcff;font-weight:500;}
.btn.undo-btn{font-size:14px;padding:3px 7px;color:rgba(255,255,255,.38);}
.btn.undo-btn:not([disabled]):hover{color:#e8eaf0;}
.btn.undo-btn[disabled]{opacity:.28;cursor:default;}
.btn.undo-btn[disabled]:hover{background:transparent;border-color:transparent;}
.btn.undo-btn{padding:4px 8px;font-size:12px;}
.btn.undo-btn:disabled{opacity:.3;cursor:default;}
.meta{font-size:10px;color:rgba(255,255,255,.22);white-space:nowrap;margin-left:auto;}
#wrap{flex:1;overflow:hidden;position:relative;}
svg{width:100%;height:100%;display:block;}
.nd{cursor:pointer;}
.nd .bg{transition:filter .12s;}
.nd:hover .bg{filter:brightness(1.3);}
.nd.selected .sel-ring{display:block;}
.sel-ring{display:none;pointer-events:none;}
.rh {opacity:0;transition:opacity .15s;cursor:ew-resize;}
.rh-b{opacity:0;transition:opacity .15s;cursor:ns-resize;}
.nd:hover .rh,.nd:hover .rh-b{opacity:1;}
.tog circle{transition:fill .12s;}
.tog:hover circle{opacity:.9;}
.edge{fill:none;}
/* context menu */
#ctx-menu{
position:fixed;display:none;z-index:300;
background:rgba(18,22,38,.98);border:1px solid rgba(255,255,255,.13);
border-radius:9px;padding:5px 0;min-width:165px;
box-shadow:0 8px 32px rgba(0,0,0,.5);font-size:13px;
}
#ctx-menu.open{display:block;}
.ctx-item{padding:7px 16px;cursor:pointer;display:flex;align-items:center;gap:9px;color:#e0e4f0;transition:background .1s;user-select:none;}
.ctx-item:hover{background:rgba(255,255,255,.08);}
.ctx-item.danger{color:#f87171;}
.ctx-item.danger:hover{background:rgba(248,113,113,.1);}
.ctx-sep{height:1px;background:rgba(255,255,255,.08);margin:4px 0;}
.ctx-icon{width:16px;text-align:center;font-size:14px;}
.ctx-colors{padding:6px 12px;display:flex;gap:6px;flex-wrap:wrap;}
.color-dot{width:18px;height:18px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:transform .1s,border-color .1s;}
.color-dot.active{border-color:#fff;transform:scale(1.25);box-shadow:0 0 0 2px rgba(255,255,255,.3);}
.color-dot:hover{transform:scale(1.2);border-color:rgba(255,255,255,.5);}
/* toast */
#toast{
position:fixed;bottom:20px;left:50%;transform:translateX(-50%);
background:rgba(13,15,26,.97);border:1px solid rgba(255,255,255,.15);
border-radius:8px;padding:7px 18px;font-size:13px;
pointer-events:none;opacity:0;transition:opacity .2s;z-index:99;
}
#toast.show{opacity:1;}
</style>
</head>
<body>
<header>
<!-- 第一行:标题 + 导出 -->
<div class="header-row top">
<h1>🧠 产品发布 v2.0</h1>
<div class="btn-group">
<span class="btn-group-label">导出</span>
<button class="btn exp" onclick="exportAs('svg')" title="导出 SVG">SVG</button>
<button class="btn exp" onclick="exportAs('png')" title="导出 PNG">PNG</button>
<button class="btn exp" onclick="exportAs('jpg')" title="导出 JPG">JPG</button>
<button class="btn exp" onclick="exportAs('pdf')" title="导出 PDF">PDF</button>
<button class="btn exp" onclick="exportXmind()" title="导出 XMind">XMind</button>
</div>
</div>
<!-- 第二行:视图控制 + 撤销 + 布局 -->
<div class="header-row">
<div class="btn-group">
<span class="btn-group-label">视图</span>
<button class="btn" onclick="resetView()" title="重置视图">⊙</button>
<button class="btn" onclick="expandAll()" title="全部展开">⊞</button>
<button class="btn" onclick="collapseAll()" title="全部折叠">⊟</button>
<button class="btn" onclick="zoomIn()" title="放大">+</button>
<button class="btn" onclick="zoomOut()" title="缩小">-</button>
</div>
<div class="btn-group">
<span class="btn-group-label">历史</span>
<button class="btn undo-btn" id="undo-btn" onclick="undo()" title="撤销 (Ctrl+Z)" disabled>↶</button>
<button class="btn undo-btn" id="redo-btn" onclick="redo()" title="重做 (Ctrl+Y)" disabled>↷</button>
</div>
<div class="btn-group">
<span class="btn-group-label">布局</span>
<button class="btn layout-btn active" id="layout-btn-0" onclick="switchLayout(0)" title="左右均衡">⇆ 左右</button>
<button class="btn layout-btn" id="layout-btn-1" onclick="switchLayout(1)" title="全向辐射">✶ 辐射</button>
<button class="btn layout-btn" id="layout-btn-2" onclick="switchLayout(2)" title="向右树形">➡ 树形</button>
<button class="btn layout-btn" id="layout-btn-3" onclick="switchLayout(3)" title="垂直向下">🌳 垂直</button>
<button class="btn layout-btn" id="layout-btn-4" onclick="switchLayout(4)" title="力导向动画">⚡ 力导向</button>
<button class="btn layout-btn" id="layout-btn-5" onclick="switchLayout(5)" title="时间线">⏩ 时间线</button>
<button class="btn layout-btn" id="layout-btn-6" onclick="switchLayout(6)" title="鱼骨图">🐟 鱼骨</button>
<button class="btn layout-btn" id="layout-btn-7" onclick="switchLayout(7)" title="括弧图">} 括弧</button>
</div>
<span class="meta">右键菜单 · Tab 添加子节点 · Del 删除 · Ctrl+Z 撤销</span>
</div>
</header>
<div id="wrap">
<svg id="svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<linearGradient id="root-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4c5fdb"/>
<stop offset="100%" stop-color="#7c8cf8"/>
</linearGradient>
</defs>
<g id="edges-g"></g>
<g id="nodes-g"></g>
</svg>
</div>
<div id="ctx-menu">
<div class="ctx-item" onclick="ctxAction('add-child')"><span class="ctx-icon">+</span>添加子节点</div>
<div class="ctx-item" onclick="ctxAction('add-sibling')"><span class="ctx-icon">↵</span>添加兄弟节点</div>
<div class="ctx-sep"></div>
<div class="ctx-item" style="font-size:11px;color:rgba(255,255,255,.4);padding:4px 16px;cursor:default;" id="ctx-color-label">更改节点颜色</div>
<div class="ctx-colors" id="ctx-colors"></div>
<div class="ctx-sep"></div>
<div class="ctx-item danger" onclick="ctxAction('delete')"><span class="ctx-icon">🗑</span>删除节点</div>
</div>
<div id="toast"></div>
<script>
const RAW = {"central": "产品发布 v2.0", "branches": [{"label": "📦 需求分析", "color": "#4A90D9", "children": [{"label": "用户调研", "children": [{"label": "问卷设计", "children": []}, {"label": "用户访谈", "children": []}, {"label": "数据分析", "children": []}]}, {"label": "竞品分析", "children": [{"label": "功能对比", "children": []}, {"label": "定价策略", "children": []}]}, {"label": "需求优先级排序", "children": []}, {"label": "PRD 文档", "children": []}]}, {"label": "📅 设计阶段", "color": "#1ABC9C", "children": [{"label": "UI 设计", "children": [{"label": "线框图", "children": []}, {"label": "视觉稿", "children": []}, {"label": "设计规范", "children": []}]}, {"label": "UX 设计", "children": [{"label": "用户流程", "children": []}, {"label": "交互原型", "children": []}]}, {"label": "设计评审", "children": []}, {"label": "可用性测试", "children": []}]}, {"label": "📅 研发阶段", "color": "#E86C3A", "children": [{"label": "前端开发", "children": [{"label": "组件库", "children": []}, {"label": "页面实现", "children": []}, {"label": "性能优化", "children": []}]}, {"label": "后端开发", "children": [{"label": "API 设计", "children": []}, {"label": "数据库", "children": []}, {"label": "微服务", "children": []}]}, {"label": "测试", "children": [{"label": "单元测试", "children": []}, {"label": "集成测试", "children": []}, {"label": "压力测试", "children": []}]}, {"label": "代码评审", "children": []}]}, {"label": "🔸 上线准备", "color": "#9B59B6", "children": [{"label": "灰度发布", "children": [{"label": "内测用户", "children": []}, {"label": "A/B 测试", "children": []}]}, {"label": "运维保障", "children": [{"label": "监控告警", "children": []}, {"label": "回滚预案", "children": []}]}, {"label": "文档更新", "children": []}, {"label": "客服培训", "children": []}]}, {"label": "📈 推广运营", "color": "#F39C12", "children": [{"label": "市场推广", "children": [{"label": "发布会", "children": []}, {"label": "媒体公关", "children": []}, {"label": "社交媒体", "children": []}]}, {"label": "数据追踪", "children": [{"label": "DAU/MAU", "children": []}, {"label": "转化率", "children": []}, {"label": "留存率", "children": []}]}, {"label": "用户反馈收集", "children": []}, {"label": "迭代规划", "children": []}]}]};
const TITLE = "产品发布 v2.0";
const SVG_NS = "http://www.w3.org/2000/svg";
const CFG = [
{h:48,fs:16,fw:"700",rx:12,px:32,minW:180},
{h:38,fs:13,fw:"600",rx: 8,px:24,minW:110},
{h:30,fs:12,fw:"400",rx: 6,px:18,minW: 80},
{h:26,fs:11,fw:"400",rx: 5,px:16,minW: 72},
];
const PALETTE=["#4A90D9","#E86C3A","#27AE60","#9B59B6","#E74C3C","#F39C12","#1ABC9C","#E91E63","#00BCD4","#8BC34A"];
const H_GAP=[0,64,48,40], V_GAP=[0,20,12,8];
const MIN_W=60, MIN_H=20, HW=7;
let nodeMap={}, _nid=0, selectedId=null, ctxTargetId=null;
let _pushScheduled=false; // rAF throttle for pushAway
let _undoStack=[], _redoStack=[]; // undo/redo snapshot stacks
const MAX_UNDO=50;
function measureW(text,depth){
const c=CFG[Math.min(depth,CFG.length-1)];
let w=0; for(const ch of String(text)) w+=ch.charCodeAt(0)>127?c.fs*0.92:c.fs*0.58;
return Math.max(c.minW,w+c.px*2);
}
function annotate(node, depth, branchColor){
node._id=node._id||"n"+(++_nid); node._depth=depth;
if(node._collapsed===undefined) node._collapsed=false;
node._pinned=node._pinned||false;
if(node._px===undefined) node._px=null;
if(node._py===undefined) node._py=null;
node._w=node._w||measureW(node.label||node.central||"",depth);
node._h=node._h||CFG[Math.min(depth,CFG.length-1)].h;
// 缓存所属分支的主题色,O(1) 查色,避免 nodeColor 每帧递归
if(depth===0) node._branchColor=null;
else if(depth===1) node._branchColor=node.color||null;
else node._branchColor=branchColor||null;
nodeMap[node._id]=node;
const bc = depth===1 ? (node.color||null) : branchColor||null;
(node.children||[]).forEach(ch=>annotate(ch,depth+1,bc));
(node.branches||[]).forEach(b=>annotate(b,1,null));
}
function visKids(node){return node._collapsed?[]:(node.children||[]);}
function newId(){_nid++;let id="n"+_nid;while(nodeMap[id]){_nid++;id="n"+_nid;}return id;}
let pos={};
let currentLayout=0; // 0=左右均衡 1=辐射 2=向右树 3=垂直树 4=力导向
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 0 — 左右均衡树(默认)
分支均分左右,每侧垂直树形,S曲线+直角折线
══════════════════════════════════════════════════════════════════════════ */
function subtreeH(node){
const vg=V_GAP[Math.min(node._depth,V_GAP.length-1)];
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h,kids.reduce((s,k)=>s+subtreeH(k),0)+vg*(kids.length-1));
}
function layoutSubtree(node,cx,cy,side){
pos[node._id]={x:cx,y:cy,parentId:pos[node._id]?.parentId,side};
const kids=visKids(node); if(!kids.length) return;
const vg=V_GAP[Math.min(node._depth+1,V_GAP.length-1)];
const hg=H_GAP[Math.min(node._depth+1,H_GAP.length-1)];
const maxCW=Math.max(...kids.map(k=>k._w));
const childCX=cx+side*(node._w/2+hg+maxCW/2);
const heights=kids.map(k=>subtreeH(k));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(kids.length-1);
let curY=cy-totalH/2;
kids.forEach((kid,i)=>{
const kcy=curY+heights[i]/2;
pos[kid._id]={x:childCX,y:kcy,parentId:node._id,side};
layoutSubtree(kid,childCX,kcy,side);
curY+=heights[i]+vg;
});
}
function layout0(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:0};
const branches=tree.branches||[]; if(!branches.length) return;
const nRight=Math.ceil(branches.length/2);
function placeSide(brs,side){
if(!brs.length) return;
const maxBW=Math.max(...brs.map(b=>b._w));
const branchCX=side*(tree._w/2+H_GAP[1]+maxBW/2);
const heights=brs.map(b=>subtreeH(b));
const totalH=heights.reduce((a,b)=>a+b,0)+V_GAP[1]*(brs.length-1);
let curY=-totalH/2;
brs.forEach((b,i)=>{
const bcy=curY+heights[i]/2;
pos[b._id]={x:branchCX,y:bcy,parentId:tree._id,side:side};
layoutSubtree(b,branchCX,bcy,side);
curY+=heights[i]+V_GAP[1];
});
}
placeSide(branches.slice(0,nRight),1);
placeSide(branches.slice(nRight),-1);
}
function edgePath0(px,py,pw,cx,cy,cw,depth,side){
/* 左右均衡布局:全程三次贝塞尔,从节点侧边水平切出/切入
控制点在水平中点,产生优雅的 S 形曲线 */
const dx = cx - px;
const s = dx >= 0 ? 1 : -1; // 实际方向
const x1 = px + s * pw/2; // 父节点出口(侧边中心)
const x2 = cx - s * cw/2; // 子节点入口(侧边中心)
// 控制点张力:depth=1 用 0.5(标准 S 曲线),深层略收紧
const t = depth === 1 ? 0.5 : 0.45;
const cpx = x1 + (x2 - x1) * t;
// 节点几乎水平对齐时退化为直线
if(Math.abs(cy - py) < 3) return `Mx1,py Lx2,cy`;
return `Mx1,py Ccpx,py cpx,cy x2,cy`;
}
function edgePath1(px,py,cx,cy,depth){
/* 辐射布局:从父节点中心到子节点中心,沿径向方向平滑贝塞尔
控制点在各自 y 保持,让线条沿水平/垂直方向自然流出 */
const mx = (px+cx)/2, my = (py+cy)/2;
const t = depth === 1 ? 0.5 : 0.42;
const cp1x = px+(cx-px)*t, cp2x = cx-(cx-px)*t;
return `Mpx,py Ccp1x,py cp2x,cy cx,cy`;
}
function edgePath2(px,py,pw,cx,cy,cw,depth){
/* 树形(向右):从父节点右边出发,到子节点左边进入,圆角肘形
保留视觉上的流程感,同时用贝塞尔圆滑转角 */
const x1 = px + pw/2; // 父右边
const x2 = cx - cw/2; // 子左边
const mid = x1 + (x2 - x1) * 0.5;
if(Math.abs(cy - py) < 3) return `Mx1,py Lx2,cy`;
// 三次贝塞尔:水平出 → 水平入,中点弯曲
return `Mx1,py Cmid,py mid,cy x2,cy`;
}
function edgePath3(px,py,pw,cx,cy,cw,depth){
/* 垂直树:中心到中心,控制点保持各自 x,产生垂直 S 曲线 */
const my = (py + cy) / 2;
if(Math.abs(cx - px) < 3) return `Mpx,py Lcx,cy`;
return `Mpx,py Cpx,my cx,my cx,cy`;
}
function edgePath4(px,py,cx,cy){
/* 力导向:点到点平滑贝塞尔,控制点在中点 */
const mx = (px+cx)/2, my = (py+cy)/2;
const dx = cx-px, dy = cy-py, len = Math.sqrt(dx*dx+dy*dy)||1;
const perp = Math.min(len*0.12, 24);
const nx = -dy/len*perp, ny = dx/len*perp;
return `Mpx,py Qmx+nx,my+ny cx,cy`;
}
function drawSpine3(){
// 垂直树不需要装饰线,清除旧环形圈
document.querySelectorAll(".circ-ring").forEach(e => e.remove());
document.getElementById("fishbone-spine")?.remove();
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 1 — 全辐射(圆形散射)
══════════════════════════════════════════════════════════════════════════ */
function layout1(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:1};
const branches=tree.branches||[]; if(!branches.length) return;
function leafCount(node){
const kids=visKids(node);
return kids.length?kids.reduce((s,k)=>s+leafCount(k),0):1;
}
const totalLeaves=branches.reduce((s,b)=>s+leafCount(b),0);
const R1=Math.max(180, tree._w/2+120);
const R2=120;
let angle=-Math.PI/2;
branches.forEach(branch=>{
const frac=leafCount(branch)/totalLeaves;
const span=frac*Math.PI*2;
const mid=angle+span/2;
angle+=span;
const side=Math.cos(mid)>=0?1:-1;
const bx=Math.cos(mid)*R1, by=Math.sin(mid)*R1;
pos[branch._id]={x:bx,y:by,parentId:tree._id,side,angle:mid};
const kids=visKids(branch);
if(!kids.length) return;
const fanSpan=Math.min(span*.8, Math.PI*.6);
const fanStart=mid-fanSpan/2;
kids.forEach((kid,i)=>{
const ka=fanStart+(fanSpan*i)/(Math.max(kids.length-1,1))||mid;
const kR=R1+R2+kid._w/2;
const kx=Math.cos(ka)*kR, ky=Math.sin(ka)*kR;
pos[kid._id]={x:kx,y:ky,parentId:branch._id,side:Math.cos(ka)>=0?1:-1,angle:ka};
const gkids=visKids(kid);
if(!gkids.length) return;
const gFan=Math.min(fanSpan/(kids.length||1)*.9, Math.PI*.3);
gkids.forEach((gk,j)=>{
const ga=ka+(j-(gkids.length-1)/2)*gFan/(Math.max(gkids.length-1,1)||1);
const gR=kR+R2*.7+gk._w/2;
pos[gk._id]={x:Math.cos(ga)*gR,y:Math.sin(ga)*gR,parentId:kid._id,side:Math.cos(ga)>=0?1:-1,angle:ga};
});
});
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 2 — 向右树(Org Chart)
══════════════════════════════════════════════════════════════════════════ */
function subtreeH2(node){
const vg=Math.max(V_GAP[Math.min(node._depth,V_GAP.length-1)],14);
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h,kids.reduce((s,k)=>s+subtreeH2(k),0)+vg*(kids.length-1));
}
function layoutSubtree2(node,lx,cy){
const kids=visKids(node); if(!kids.length) return;
const hg=H_GAP[Math.min(node._depth+1,H_GAP.length-1)]+8;
const vg=Math.max(V_GAP[Math.min(node._depth+1,V_GAP.length-1)],14);
const maxCW=Math.max(...kids.map(k=>k._w));
const childLX=lx+node._w+hg;
const childCX=childLX+maxCW/2;
const heights=kids.map(k=>subtreeH2(k));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(kids.length-1);
let curY=cy-totalH/2;
kids.forEach((kid,i)=>{
const kcy=curY+heights[i]/2;
pos[kid._id]={x:childCX,y:kcy,parentId:node._id,side:1};
layoutSubtree2(kid,childLX,kcy);
curY+=heights[i]+vg;
});
}
function layout2(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:1};
const branches=tree.branches||[]; if(!branches.length) return;
const hg=H_GAP[1]+8;
const vg=Math.max(V_GAP[1],14);
const maxBW=Math.max(...branches.map(b=>b._w));
const branchLX=tree._w/2+hg;
const branchCX=branchLX+maxBW/2;
const heights=branches.map(b=>subtreeH2(b));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(branches.length-1);
let curY=-totalH/2;
branches.forEach((b,i)=>{
const bcy=curY+heights[i]/2;
pos[b._id]={x:branchCX,y:bcy,parentId:tree._id,side:1};
layoutSubtree2(b,branchLX,bcy);
curY+=heights[i]+vg;
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 3 — 垂直树(Vertical Tree / Top-Down)
══════════════════════════════════════════════════════════════════════════ */
function subtreeW3(node){
const hg=20;
const kids=visKids(node);
if(!kids.length) return node._w;
const childrenW=kids.reduce((s,k)=>s+subtreeW3(k),0)+hg*(kids.length-1);
return Math.max(node._w,childrenW);
}
function placeSubtree3(node,cx,top,parentId){
const cy=top+node._h/2;
pos[node._id]={x:cx,y:cy,parentId,side:1};
const kids=visKids(node); if(!kids.length) return;
const V_STEP=80, H_GAP_3=20;
const childTop=top+node._h+V_STEP;
const widths=kids.map(k=>subtreeW3(k));
const totalW=widths.reduce((a,b)=>a+b,0)+H_GAP_3*(kids.length-1);
let curX=cx-totalW/2;
kids.forEach((kid,i)=>{
const kidCX=curX+widths[i]/2;
placeSubtree3(kid,kidCX,childTop,node._id);
curX+=widths[i]+H_GAP_3;
});
}
function layout3(tree){
pos={};
const branches=tree.branches||[];
pos[tree._id]={x:0,y:0,parentId:null,side:1};
if(!branches.length) return;
const V_STEP=80, H_GAP_3=20;
const top1=tree._h/2+V_STEP;
const widths=branches.map(b=>subtreeW3(b));
const totalW=widths.reduce((a,b)=>a+b,0)+H_GAP_3*(branches.length-1);
let curX=-totalW/2;
branches.forEach((branch,i)=>{
const bx=curX+widths[i]/2;
placeSubtree3(branch,bx,top1,tree._id);
curX+=widths[i]+H_GAP_3;
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 4 — 力导向(Force-Directed)
· Coulomb 斥力 + Hooke 弹簧 + Verlet 积分
· 根节点固定在中心,其余节点自由浮动
· 120 帧动画后定格
══════════════════════════════════════════════════════════════════════════ */
let _fdRunning = false;
let _fdTimer = null;
function layout4(tree){
stopFD();
// ── 1. 收集所有节点(全树,不管 collapsed)────────────────────────────
const all = [];
;(function walk(node){
all.push(node);
(node._depth===0 ? (node.branches||[]) : visKids(node)).forEach(walk);
})(tree);
// ── 2. 从树结构建边(不依赖 pos,避免 stale 问题)─────────────────────
const edges = [];
;(function walkE(node){
const kids = node._depth===0 ? (node.branches||[]) : visKids(node);
kids.forEach(kid=>{ edges.push([node._id, kid._id]); walkE(kid); });
})(tree);
// ── 3. 先用 layout0 给一个合理初始骨架,再叠加力导向 ─────────────────
layout0(tree);
// ── 4. 给 pos 里没有的节点(collapsed)补一个随机初始位置 ───────────────
const seed = () => (Math.random()-0.5)*80;
all.forEach(node=>{
if(!pos[node._id]){
// 找父节点位置作为起点
const parentId = (node._depth===0) ? null
: edges.find(([a,b])=>b===node._id)?.[0] ?? null;
const pp = parentId ? pos[parentId] : null;
pos[node._id] = {
x: (pp ? pp.x : 0) + seed(),
y: (pp ? pp.y : 0) + seed(),
parentId, side:1, vx:0, vy:0
};
} else {
pos[node._id].vx = 0;
pos[node._id].vy = 0;
}
});
// ── 5. 力导向迭代 ────────────────────────────────────────────────────
const K_REPEL = 30000;
const K_SPRING = 0.09;
const DAMPING = 0.80;
const MAX_V = 55;
const FRAMES = 130;
function idealLen(depthA, depthB){ return 150 + Math.max(depthA,depthB)*35; }
let frame = 0;
function tick(){
if(!_fdRunning || currentLayout!==4){ _fdRunning=false; return; }
frame++;
const cool = Math.max(0.04, 1 - frame/FRAMES);
const fx={}, fy={};
all.forEach(n=>{ fx[n._id]=0; fy[n._id]=0; });
// Coulomb 斥力(所有节点对)
for(let i=0;i<all.length;i++){
const a=all[i], pa=pos[a._id];
if(!pa) continue;
for(let j=i+1;j<all.length;j++){
const b=all[j], pb=pos[b._id];
if(!pb) continue;
let dx=pb.x-pa.x, dy=pb.y-pa.y;
const dist2=dx*dx+dy*dy||0.01;
const dist=Math.sqrt(dist2);
// 额外排斥:节点尺寸内强推
const minD=(a._w+b._w)*0.5+24;
const f=K_REPEL/dist2*cool;
const push=dist<minD?(minD-dist)*1.2:0;
const ux=dx/dist, uy=dy/dist;
fx[a._id]-=(f+push)*ux; fy[a._id]-=(f+push)*uy;
fx[b._id]+=(f+push)*ux; fy[b._id]+=(f+push)*uy;
}
}
// Hooke 弹簧引力(有边的节点对)
edges.forEach(([aid,bid])=>{
const pa=pos[aid], pb=pos[bid];
if(!pa||!pb) return;
const dx=pb.x-pa.x, dy=pb.y-pa.y;
const dist=Math.sqrt(dx*dx+dy*dy)||0.01;
const na=nodeMap[aid]||{_depth:0}, nb=nodeMap[bid]||{_depth:1};
const target=idealLen(na._depth, nb._depth);
const stretch=(dist-target)*K_SPRING*cool;
const ux=dx/dist, uy=dy/dist;
if(aid!==tree._id){ fx[aid]+=stretch*ux; fy[aid]+=stretch*uy; }
fx[bid]-=stretch*ux; fy[bid]-=stretch*uy;
});
// 弱中心引力(防止整体漂移)
all.forEach(node=>{
if(node._id===tree._id) return;
const p=pos[node._id]; if(!p) return;
fx[node._id]-=p.x*0.006*cool;
fy[node._id]-=p.y*0.006*cool;
});
// 更新速度和位置
all.forEach(node=>{
if(node._id===tree._id) return;
const p=pos[node._id]; if(!p) return;
p.vx=(p.vx+fx[node._id])*DAMPING;
p.vy=(p.vy+fy[node._id])*DAMPING;
const spd=Math.sqrt(p.vx*p.vx+p.vy*p.vy)||1;
if(spd>MAX_V){ p.vx=p.vx/spd*MAX_V; p.vy=p.vy/spd*MAX_V; }
p.x+=p.vx; p.y+=p.vy;
p.side=p.x>=0?1:-1;
});
renderAll(tree);
if(frame<FRAMES){
_fdTimer=requestAnimationFrame(tick);
} else {
_fdRunning=false;
}
}
_fdRunning=true;
frame=0;
_fdTimer=requestAnimationFrame(tick);
}
function stopFD(){
_fdRunning = false;
if(_fdTimer){ cancelAnimationFrame(_fdTimer); _fdTimer=null; }
}
function edgePath4(px,py,cx,cy){
// 力导向用平滑曲线
const mx=(px+cx)/2, my=(py+cy)/2;
const dx=cx-px, dy=cy-py, len=Math.sqrt(dx*dx+dy*dy)||1;
// 控制点:垂直于连线方向偏移,形成弧线
const perp = Math.min(len*0.15, 30);
const nx=-dy/len*perp, ny=dx/len*perp;
return `Mpx,py Qmx+nx,my+ny cx,cy`;
}
function drawSpine3(){
document.querySelectorAll(".circ-ring,.fd-ring").forEach(e=>e.remove());
document.getElementById("fishbone-spine")?.remove();
document.getElementById("timeline-axis")?.remove();
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 5 — 时间线(Timeline / 水平流程)
· 中心节点在最左侧
· 主分支从左到右等间距水平排列
· 子节点垂直向下展开
· 主分支之间有水平时间轴主干线
══════════════════════════════════════════════════════════════════════════ */
function subtreeH5(node){
const vg=12;
const kids=visKids(node); if(!kids.length) return node._h;
return node._h + 60 + kids.reduce((s,k)=>s+k._h+vg,0) - vg;
}
function layout5(tree){
pos={};
const branches=tree.branches||[];
// root at far left
pos[tree._id]={x:0, y:0, parentId:null, side:1};
if(!branches.length) return;
const H_STEP = 220; // horizontal distance between branch columns
const V_TOP = 90; // vertical distance from timeline axis to first child
// Place branches horizontally
branches.forEach((b,i)=>{
const bx = tree._w/2 + H_STEP * (i+1);
pos[b._id]={x:bx, y:0, parentId:tree._id, side:1};
// Children stacked vertically below
const kids=visKids(b); if(!kids.length) return;
let curY = V_TOP;
kids.forEach(kid=>{
pos[kid._id]={x:bx, y:curY, parentId:b._id, side:1};
// Grandchildren further right
const gkids=visKids(kid); if(!gkids.length){ curY+=kid._h+12; return; }
let gy=curY;
gkids.forEach(gk=>{
pos[gk._id]={x:bx+kid._w/2+80+gk._w/2, y:gy, parentId:kid._id, side:1};
gy+=gk._h+8;
});
curY=Math.max(curY+kid._h+12, gy);
});
});
}
function edgePath5(px,py,pw,cx,cy,cw,depth){
if(depth===1){
// Timeline axis: horizontal straight line
const x1=px+pw/2, x2=cx-cw/2;
return `Mx1,py Lx2,cy`;
}
// Branch to children: vertical drop then horizontal
if(Math.abs(cx-px)<3){
// Straight down
const y1=py+20, y2=cy-cw/4;
return `Mpx,y1 Lcx,y2`;
}
// Horizontal bezier for grandchildren
const x1=px+pw/2, x2=cx-cw/2;
const mid=(x1+x2)/2;
if(Math.abs(cy-py)<3) return `Mx1,py Lx2,cy`;
return `Mx1,py Cmid,py mid,cy x2,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 6 — 鱼骨图(Fishbone / Ishikawa)
· 中心节点(鱼头)在右侧
· 水平主干(鱼脊)从右向左延伸
· 主分支交替从上下两侧 45° 斜向伸出(鱼骨)
· 子节点沿鱼骨方向排列
══════════════════════════════════════════════════════════════════════════ */
function layout6(tree){
pos={};
const branches=tree.branches||[];
// Fish head (root) on the right
pos[tree._id]={x:0, y:0, parentId:null, side:1};
if(!branches.length) return;
const SPINE_STEP = 180; // distance between bones along spine
const BONE_LEN = 130; // length of each bone (diagonal)
const SUB_GAP = 36; // gap between sub-nodes along bone
const ANGLE = Math.PI * 0.38; // ~68° from horizontal
branches.forEach((b,i)=>{
const spineX = -(tree._w/2 + 80 + SPINE_STEP * i);
const upDown = (i % 2 === 0) ? -1 : 1; // alternate up/down
const bx = spineX - Math.cos(ANGLE) * BONE_LEN;
const by = upDown * Math.sin(ANGLE) * BONE_LEN;
pos[b._id]={x:bx, y:by, parentId:tree._id, side:-1, _spineX:spineX};
// Children along the bone direction
const kids=visKids(b); if(!kids.length) return;
const dx = Math.cos(ANGLE) * SUB_GAP * upDown * 0;
const dirX = -Math.cos(ANGLE);
const dirY = upDown * Math.sin(ANGLE);
kids.forEach((kid,j)=>{
const dist = SUB_GAP * (j+1) + kid._w/2;
const kx = bx + dirX * dist * 0.3;
const ky = by + dirY * dist;
pos[kid._id]={x:kx, y:ky, parentId:b._id, side:-1};
// Grandchildren
const gkids=visKids(kid); if(!gkids.length) return;
gkids.forEach((gk,gi)=>{
pos[gk._id]={
x: kx - gk._w/2 - kid._w/2 - 30,
y: ky + (gi - (gkids.length-1)/2) * (gk._h + 6),
parentId:kid._id, side:-1
};
});
});
});
}
function edgePath6(px,py,pw,cx,cy,cw,depth,node){
if(depth===1){
// Bone: from spine attachment point to branch node
const spineX = pos[node?._id]?._spineX;
if(spineX !== undefined){
// Draw: spine point → branch node
return `MspineX,py Lcx,cy`;
}
return `Mpx,py Lcx,cy`;
}
// Sub-bones: straight lines
return `Mpx,py Lcx,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 7 — 括弧图(Brace Map / 层级分解)
· 中心节点在最左侧
· 父节点与子节点之间绘制 SVG 大括号 "}"
· 大括号的尖端对准父节点右侧,两端包裹所有子节点
· 强调 整体 → { 部分1, 部分2, ... } 的分解关系
══════════════════════════════════════════════════════════════════════════ */
function subtreeH7(node){
const vg=16;
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h, kids.reduce((s,k)=>s+subtreeH7(k),0)+vg*(kids.length-1));
}
function layout7(tree){
pos={};
pos[tree._id]={x:0, y:0, parentId:null, side:1};
const branches=tree.branches||[]; if(!branches.length) return;
const BRACE_W = 40; // width of the brace symbol area
const H_GAP_7 = 36; // gap between parent right edge and brace
const H_GAP_C = 20; // gap between brace and children left edge
function placeChildren(node, nodeRightX, cy){
const kids=visKids(node); if(!kids.length) return;
const vg=16;
const maxCW = Math.max(...kids.map(k=>k._w));
const childLX = nodeRightX + H_GAP_7 + BRACE_W + H_GAP_C;
const childCX = childLX + maxCW/2;
const heights = kids.map(k=>subtreeH7(k));
const totalH = heights.reduce((a,b)=>a+b,0) + vg*(kids.length-1);
let curY = cy - totalH/2;
kids.forEach((kid,i)=>{
const kcy = curY + heights[i]/2;
pos[kid._id]={x:childCX, y:kcy, parentId:node._id, side:1};
placeChildren(kid, childLX + maxCW/2, kcy);
curY += heights[i] + vg;
});
}
const maxBW = Math.max(...branches.map(b=>b._w));
const branchLX = tree._w/2 + H_GAP_7 + BRACE_W + H_GAP_C;
const branchCX = branchLX + maxBW/2;
const heights = branches.map(b=>subtreeH7(b));
const totalH = heights.reduce((a,b)=>a+b,0) + 16*(branches.length-1);
let curY = -totalH/2;
branches.forEach((b,i)=>{
const bcy = curY + heights[i]/2;
pos[b._id]={x:branchCX, y:bcy, parentId:tree._id, side:1};
placeChildren(b, branchLX + maxBW/2, bcy);
curY += heights[i] + 16;
});
}
function edgePath7(px,py,pw,cx,cy,cw,depth){
/* Brace Map edge: smooth cubic Bezier with a visible "step" shape.
Unlike tree layout's S-curve (which goes directly from parent to child),
the brace path goes: parent → horizontal exit → step down/up → horizontal enter → child
This creates the visual "}" bracket grouping effect.
Uses only C (cubic bezier) commands — no Q or L — for clean anti-aliased rendering.
*/
const x1 = px + pw/2; // parent right edge
const x2 = cx - cw/2; // child left edge
const midX = x1 + (x2 - x1) * 0.42; // vertical transit x
// Same height → simple S-curve
if(Math.abs(cy - py) < 4){
const cp = x1 + (x2 - x1) * 0.5;
return `Mx1,py Ccp,py cp,cy x2,cy`;
}
// Two-segment cubic bezier: parent→midpoint, midpoint→child
// Segment 1: horizontal exit from parent, curve down/up to midX
// Segment 2: from midX, curve horizontally into child
return `Mx1,py CmidX,py midX,py midX,(py+cy)/2 `
+ `CmidX,cy midX,cy x2,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT DISPATCHER
══════════════════════════════════════════════════════════════════════════ */
function layout(tree){
if(currentLayout===4){ layout4(tree); return; }
stopFD();
if(currentLayout===0) layout0(tree);
else if(currentLayout===1) layout1(tree);
else if(currentLayout===2) layout2(tree);
else if(currentLayout===3) layout3(tree);
else if(currentLayout===5) layout5(tree);
else if(currentLayout===6) layout6(tree);
else if(currentLayout===7) layout7(tree);
}
function edgePath(px,py,pw,cx,cy,cw,depth,side){
if(currentLayout===0) return edgePath0(px,py,pw,cx,cy,cw,depth,side);
if(currentLayout===1) return edgePath1(px,py,cx,cy,depth);
if(currentLayout===2) return edgePath2(px,py,pw,cx,cy,cw,depth);
if(currentLayout===3) return edgePath3(px,py,pw,cx,cy,cw,depth);
if(currentLayout===4) return edgePath4(px,py,cx,cy);
if(currentLayout===5) return edgePath5(px,py,pw,cx,cy,cw,depth);
if(currentLayout===6) return edgePath6(px,py,pw,cx,cy,cw,depth);
if(currentLayout===7) return edgePath7(px,py,pw,cx,cy,cw,depth);
return edgePath0(px,py,pw,cx,cy,cw,depth,side);
}
function switchLayout(n){
stopFD();
currentLayout=n;
Object.values(nodeMap).forEach(node=>{node._pinned=false;node._px=null;node._py=null;});
for(let i=0;i<8;i++){
const btn=document.getElementById("layout-btn-"+i);
if(btn) btn.classList.toggle("active",i===n);
}
rebuild();
resetView();
}
function nodeColor(node){
if(node.color) return node.color;
// 使用 annotate 时缓存的分支颜色,O(1) 查找
if(node._branchColor) return node._branchColor;
return "#888";
}
function el(tag,attrs){
const e=document.createElementNS(SVG_NS,tag);
if(attrs) for(const[k,v]of Object.entries(attrs)) e.setAttribute(k,v);
return e;
}
let edgeEls={}, nodeEls={};
function renderAll(tree){
document.getElementById("nodes-g").innerHTML="";
document.getElementById("edges-g").innerHTML="";
edgeEls={}; nodeEls={};
const all=[],q=[tree];
while(q.length){const n=q.shift();all.push(n);(n._depth===0?(n.branches||[]):visKids(n)).forEach(c=>q.push(c));}
const eg=document.getElementById("edges-g");
all.forEach(node=>{
const p=pos[node._id]; if(!p||p.parentId==null) return;
const pp=pos[p.parentId]; if(!pp) return;
const pNode=nodeMap[p.parentId]||tree, color=nodeColor(node), depth=node._depth, side=p.side||1;
// 线宽和透明度随深度自然收细,产生视觉层次感
const sw = depth===1 ? 2.2 : depth===2 ? 1.5 : 1.1;
const so = depth===1 ? 0.80 : depth===2 ? 0.55 : 0.38;
const path=el("path",{class:"edge",stroke:color,
"stroke-width":sw,
"stroke-opacity":so,
"stroke-linecap":"round","stroke-linejoin":"round","data-nid":node._id});
path.setAttribute("d",edgePath(pp.x,pp.y,pNode._w,p.x,p.y,node._w,depth,side,node._id));
if(pNode._collapsed) path.style.display="none";
eg.appendChild(path); edgeEls[node._id]=path;
});
const ng=document.getElementById("nodes-g");
all.forEach(node=>renderNode(node,ng));
if(currentLayout===3) drawSpine3();
applyTransform();
}
function refreshEdgesFor(id){
const p=pos[id]; if(!p) return;
const node=nodeMap[id]||TREE, path=edgeEls[id];
// 更新到父节点的边
if(path&&p.parentId!=null){
const pp=pos[p.parentId],pN=nodeMap[p.parentId]||TREE;
if(pp) path.setAttribute("d",edgePath(pp.x,pp.y,pN._w,p.x,p.y,node._w,node._depth,p.side||1));
}
// 只更新直接子节点的边(不再深度递归),用 visKids 跳过折叠子树
const kids=node._depth===0?(node.branches||[]):visKids(node);
kids.forEach(kid=>{
const cp=pos[kid._id],cp2=edgeEls[kid._id];
if(cp&&cp2) cp2.setAttribute("d",edgePath(p.x,p.y,node._w,cp.x,cp.y,kid._w,kid._depth,cp.side||1,kid._id));
// 继续向下更新(子节点位置没变,但父位置变了,所以子节点的边起点也变了)
refreshEdgesFor(kid._id);
});
}
function renderNode(node,g){
const p=pos[node._id]; if(!p) return;
const depth=node._depth, c=CFG[Math.min(depth,CFG.length-1)];
const w=node._w, h=node._h, color=nodeColor(node);
const label=node.label||node.central||"";
const kids=node.children||node.branches||[];
const grp=el("g",{class:"nd"+(node._id===selectedId?" selected":""),"data-id":node._id,
transform:`translate(p.x-w/2,p.y-h/2)`});
// selection ring
grp.appendChild(el("rect",{class:"sel-ring",x:-3,y:-3,width:w+6,height:h+6,
rx:c.rx+3,fill:"none",stroke:"#7c8cf8","stroke-width":"2","stroke-dasharray":"5 3",opacity:.8}));
// bg
const bg=el("rect",{class:"bg",width:w,height:h,rx:c.rx,ry:c.rx});
if(depth===0){bg.setAttribute("fill","url(#root-grad)");bg.setAttribute("filter","url(#glow)");}
else if(depth===1){bg.setAttribute("fill",color+"30");bg.setAttribute("stroke",color);bg.setAttribute("stroke-width","2");}
else if(depth===2){bg.setAttribute("fill",color+"18");bg.setAttribute("stroke",color+"bb");bg.setAttribute("stroke-width","1.5");}
else{bg.setAttribute("fill",color+"0e");bg.setAttribute("stroke",color+"77");bg.setAttribute("stroke-width","1");}
// label
const tc=depth<=1?"#fff":depth===2?"#e0e4f0":"#a8b0c8";
const txt=el("text",{x:w/2,y:h/2,"dominant-baseline":"central","text-anchor":"middle",
"font-size":c.fs,"font-weight":c.fw,fill:tc,style:"pointer-events:none;user-select:none;"});
txt.textContent=label;
grp.appendChild(bg); grp.appendChild(txt);
// collapse toggle
if(kids.length&&depth>0){
const bx=w-9,by=h-9;
const tg=el("g",{class:"tog","data-id":node._id});
const tc2=el("circle",{cx:bx,cy:by,r:8,fill:color+"33",stroke:color,"stroke-width":"1.2"});
const tt=el("text",{x:bx,y:by,"dominant-baseline":"central","text-anchor":"middle",
"font-size":"11","font-weight":"700",fill:color,style:"pointer-events:none;user-select:none;"});
tt.textContent=node._collapsed?"+":" −";
tg.appendChild(tc2); tg.appendChild(tt);
tg.addEventListener("mousedown",e=>e.stopPropagation());
tg.addEventListener("click",e=>{e.stopPropagation();toggle(node._id);});
grp.appendChild(tg);
}
// resize handles
grp.appendChild(el("rect",{class:"rh",x:w-HW,y:h*.15,width:HW*2,height:h*.7,rx:3,fill:color,"data-resize":"w","data-id":node._id}));
grp.appendChild(el("rect",{class:"rh-b",x:w*.15,y:h-HW,width:w*.7,height:HW*2,rx:3,fill:color,"data-resize":"h","data-id":node._id}));
grp.addEventListener("mousedown",e=>{
if(e.target.closest(".tog")||e.target.dataset.resize) return;
e.stopPropagation(); selectNode(node._id); startNodeDrag(e,node);
});
grp.addEventListener("contextmenu",e=>{
e.preventDefault(); e.stopPropagation(); selectNode(node._id); openCtxMenu(e.clientX,e.clientY,node._id);
});
g.appendChild(grp); nodeEls[node._id]=grp;
}
function patchNodeEl(node){
const grp=nodeEls[node._id]; if(!grp) return;
const p=pos[node._id]; if(!p) return;
const w=node._w,h=node._h;
grp.setAttribute("transform",`translate(p.x-w/2,p.y-h/2)`);
const bg=grp.querySelector(".bg");if(bg){bg.setAttribute("width",w);bg.setAttribute("height",h);}
const t=grp.querySelector("text[dominant-baseline]");if(t){t.setAttribute("x",w/2);t.setAttribute("y",h/2);}
const sr=grp.querySelector(".sel-ring");if(sr){sr.setAttribute("width",w+6);sr.setAttribute("height",h+6);}
const rh=grp.querySelector("[data-resize='w']");if(rh){rh.setAttribute("x",w-HW);rh.setAttribute("y",h*.15);rh.setAttribute("height",h*.7);}
const rb=grp.querySelector("[data-resize='h']");if(rb){rb.setAttribute("x",w*.15);rb.setAttribute("y",h-HW);rb.setAttribute("width",w*.7);}
const tg=grp.querySelector(".tog");
if(tg){const bc=tg.querySelector("circle"),bt=tg.querySelector("text");
if(bc){bc.setAttribute("cx",w-9);bc.setAttribute("cy",h-9);}
if(bt){bt.setAttribute("x",w-9);bt.setAttribute("y",h-9);}}
}
/* ══ SELECTION ══ */
function selectNode(id){
if(selectedId&&nodeEls[selectedId]) nodeEls[selectedId].classList.remove("selected");
selectedId=id;
if(id&&nodeEls[id]) nodeEls[id].classList.add("selected");
}
/* ══ CONTEXT MENU ══ */
function buildColorDots(){
const cont=document.getElementById("ctx-colors"); cont.innerHTML="";
const curNode=nodeMap[ctxTargetId];
const curColor=curNode?.color||null;
PALETTE.forEach(c=>{
const d=document.createElement("div");
d.className="color-dot"+(c===curColor?" active":"");
d.style.background=c; d.title=c;
d.onclick=()=>ctxAction("color",c); cont.appendChild(d);
});
const r=document.createElement("div");
r.className="color-dot"+(curColor===null?" active":"");
r.style.background="rgba(255,255,255,.15)";
r.style.cssText+=";font-size:11px;display:flex;align-items:center;justify-content:center;";
r.title="自动颜色";r.textContent="↺";r.onclick=()=>ctxAction("color",null);
cont.appendChild(r);
}
function openCtxMenu(x,y,id){
ctxTargetId=id; buildColorDots();
// Update color label to show what will be changed
const lbl=document.getElementById("ctx-color-label");
if(lbl){
const n=nodeMap[id];
const depth=n?n._depth:0;
if(depth===0) lbl.textContent="更改根节点颜色";
else if(depth===1) lbl.textContent="更改分支颜色(影响子节点默认色)";
else lbl.textContent="更改此节点颜色";
}
const menu=document.getElementById("ctx-menu"); menu.classList.add("open");
menu.style.left=x+"px"; menu.style.top=y+"px";
requestAnimationFrame(()=>{
const r=menu.getBoundingClientRect();
if(r.right>window.innerWidth) menu.style.left=(x-r.width)+"px";
if(r.bottom>window.innerHeight) menu.style.top=(y-r.height)+"px";
});
}
function closeCtxMenu(){document.getElementById("ctx-menu").classList.remove("open");ctxTargetId=null;}
function ctxAction(action,extra){
// Save target id BEFORE closeCtxMenu() nulls ctxTargetId
const targetId=ctxTargetId;
closeCtxMenu();
const node=nodeMap[targetId]; if(!node&&action!=="delete") return;
if(action==="add-child"){
snapshotForUndo();
const d=Math.min(node._depth+1,CFG.length-1);
const nn={_id:newId(),label:"新节点",children:[],_depth:d,_collapsed:false,_pinned:false,_px:null,_py:null};
nn._w=measureW("新节点",d); nn._h=CFG[d].h; nodeMap[nn._id]=nn;
// Root node uses .branches, all others use .children
if(node._depth===0){
if(!node.branches) node.branches=[];
node.branches.push(nn);
} else {
if(!node.children) node.children=[];
node.children.push(nn);
}
rebuild();
setTimeout(()=>selectNode(nn._id), 30);
return;
}
if(action==="add-sibling"){
snapshotForUndo();
const p=pos[targetId]; if(!p||!p.parentId){showToast("根节点无法添加兄弟节点");return;}
const parent=nodeMap[p.parentId]||TREE;
const arr=parent._depth===0?(parent.branches||[]):(parent.children||[]);
const idx=arr.findIndex(c=>c._id===targetId);
const d=node._depth;
const nn={_id:newId(),label:"新节点",children:[],_depth:d,_collapsed:false,_pinned:false,_px:null,_py:null};
nn._w=measureW("新节点",d); nn._h=CFG[Math.min(d,CFG.length-1)].h; nodeMap[nn._id]=nn;
arr.splice(idx+1,0,nn); rebuild();
setTimeout(()=>selectNode(nn._id), 30);
return;
}
if(action==="delete"){
if(!node){return;} if(node._depth===0){showToast("不能删除根节点");return;}
snapshotForUndo();
const p=pos[targetId]; if(!p||!p.parentId) return;
const parent=nodeMap[p.parentId]||TREE;
const arr=parent._depth===0?(parent.branches||[]):(parent.children||[]);
const idx=arr.findIndex(c=>c._id===targetId);
if(idx>=0) arr.splice(idx,1);
function rm(n){delete nodeMap[n._id];(n.children||[]).forEach(rm);}
rm(node);
if(selectedId===targetId) selectNode(null);
rebuild(); return;
}
if(action==="color"){
snapshotForUndo();
// Set color on the exact node clicked — no walk-up to branch root
if(extra===null) delete node.color; else node.color=extra;
rebuild(); return;
}
}
// Use mousedown (not click) to close menu so it doesn't race with menu item onclick
document.addEventListener("mousedown",e=>{
const menu=document.getElementById("ctx-menu");
if(menu.classList.contains("open")&&!menu.contains(e.target)) closeCtxMenu();
});
document.addEventListener("contextmenu",e=>{
if(["wrap","svg","edges-g","nodes-g"].includes(e.target.id)||(e.target.tagName==="svg")||(e.target.parentElement&&e.target.parentElement.id==="edges-g"))
e.preventDefault();
});
/* ══ UNDO / REDO ══════════════════════════════════════════════════════════
操作前调用 snapshotForUndo(),将当前树结构序列化压入撤销栈。
Ctrl+Z 弹出并恢复,Ctrl+Y/Ctrl+Shift+Z 重做。
════════════════════════════════════════════════════════════════════════ */
function _treeSnapshot(){
// 序列化当前树(含颜色、折叠状态、位置)
function snap(node){
const out={label:node.label,central:node.central,color:node.color,
_collapsed:node._collapsed,_pinned:node._pinned,_px:node._px,_py:node._py,
_w:node._w,_h:node._h};
if((node.children||[]).length) out.children=(node.children||[]).map(snap);
if((node.branches||[]).length) out.branches=(node.branches||[]).map(snap);
return out;
}
return JSON.stringify(snap(TREE));
}
function _restoreSnapshot(json){
const saved=JSON.parse(json);
function restore(live,saved){
live.label=saved.label; live.central=saved.central;
if(saved.color) live.color=saved.color; else delete live.color;
live._collapsed=saved._collapsed||false;
live._pinned=saved._pinned||false;
live._px=saved._px??null; live._py=saved._py??null;
live._w=saved._w||null; live._h=saved._h||null;
// Rebuild children array from saved data
if(saved.children){
live.children=(saved.children).map(sc=>{
const n={label:sc.label||"",children:[],branches:[]};
restore(n,sc); return n;
});
} else { live.children=[]; }
if(saved.branches){
live.branches=(saved.branches).map(sb=>{
const n={label:sb.label||"",children:[],branches:[]};
restore(n,sb); return n;
});
}
}
restore(TREE,saved);
// 清理所有可能持有旧节点引用的状态,防止 stale reference crash
activeOp = null;
wrap.style.cursor = "";
selectNode(null);
ctxTargetId = null;
_pushScheduled = false;
// 关键修复:_nid 重置为 0 后,必须清除 TREE._id,否则 TREE 保留旧 _id(如 "n1"),
// 而 annotate 从 n1 开始分配,导致第一个分支也拿到 "n1",产生 ID 碰撞,
// nodeMap["n1"] 被分支覆盖,TREE 从 nodeMap 消失,渲染完全混乱。
delete TREE._id;
_nid=0; nodeMap={};
annotate(TREE,0,null);
rebuild();
}
function snapshotForUndo(){
_undoStack.push(_treeSnapshot());
if(_undoStack.length>MAX_UNDO) _undoStack.shift();
_redoStack=[]; // new action clears redo
}
function undo(){
if(!_undoStack.length){ showToast("没有可撤销的操作",1600); return; }
_redoStack.push(_treeSnapshot());
_restoreSnapshot(_undoStack.pop());
showToast("↩ 已撤销",1400);
}
function redo(){
if(!_redoStack.length){ showToast("没有可重做的操作",1600); return; }
_undoStack.push(_treeSnapshot());
_restoreSnapshot(_redoStack.pop());
showToast("↪ 已重做",1400);
}
/* ══ KEYBOARD ══ */
document.addEventListener("keydown",e=>{
// Undo / Redo
if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&e.key==="z"){e.preventDefault();undo();return;}
if((e.ctrlKey||e.metaKey)&&(e.key==="y"||(e.shiftKey&&e.key==="z"))){e.preventDefault();redo();return;}
if((e.key==="Delete"||e.key==="Backspace")&&selectedId){
e.preventDefault();
ctxTargetId=selectedId; ctxAction("delete"); return;
}
if(e.key==="Tab"&&selectedId){
e.preventDefault();
ctxTargetId=selectedId; ctxAction("add-child"); return;
}
if(e.key==="Escape"){selectNode(null);closeCtxMenu();}
});
/* ══ DRAG REPULSION ══════════════════════════════════════════════════════
链式传播算法:
1. 以被拖动节点为压力源,计算每个节点到压力源的距离
2. 按距离从近到远排序,依次推开——近的节点先让位,压力向外传播
3. 推开时近压力源的节点固定(已被推过),只推远端节点
4. 多轮迭代直到全局无重叠,避免振荡
════════════════════════════════════════════════════════════════════════ */
const DRAG_PAD = 10;
const MAX_ITER = 15;
function pushAway(draggedId){
const allIds = Object.keys(pos);
if(allIds.length < 2) return;
// 预计算半尺寸
const hw = {}, hh = {};
allIds.forEach(id => {
const n = nodeMap[id] || TREE;
hw[id] = n._w/2 + DRAG_PAD/2;
hh[id] = n._h/2 + DRAG_PAD/2;
});
const dp = pos[draggedId];
for(let iter = 0; iter < MAX_ITER; iter++){
let anyOverlap = false;
// 按到拖动节点的距离从近到远排序,让压力从内向外传播
const sorted = allIds.slice().sort((a, b) => {
const pa = pos[a], pb = pos[b];
const da = (pa.x-dp.x)**2 + (pa.y-dp.y)**2;
const db = (pb.x-dp.x)**2 + (pb.y-dp.y)**2;
return da - db;
});
for(let i = 0; i < sorted.length; i++){
for(let j = i+1; j < sorted.length; j++){
const ai = sorted[i], aj = sorted[j]; // ai 比 aj 更靠近拖动节点
const pi = pos[ai], pj = pos[aj];
if(!pi || !pj) continue;
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const overlapX = (hw[ai] + hw[aj]) - Math.abs(dx);
const overlapY = (hh[ai] + hh[aj]) - Math.abs(dy);
if(overlapX <= 0 || overlapY <= 0) continue;
anyOverlap = true;
// 关键:ai 更靠近压力源(已被处理过),固定 ai 只推 aj
// 压力单向向外传播,不会产生振荡
if(overlapX <= overlapY){
pj.x += overlapX * (dx >= 0 ? 1 : -1);
} else {
pj.y += overlapY * (dy >= 0 ? 1 : -1);
}
}
}
if(!anyOverlap) break;
}
// 同步视觉、side 和 pinned 坐标
allIds.forEach(id => {
const p = pos[id]; if(!p) return;
const n = nodeMap[id] || TREE;
// 更新 side:始终以父节点为参照
const parentP = p.parentId ? pos[p.parentId] : null;
if(parentP) p.side = p.x >= parentP.x ? 1 : -1;
if(n._pinned){ n._px = p.x; n._py = p.y; }
patchNodeEl(n);
refreshEdgesFor(id);
});
}
/* ══ GLOBAL SEPARATION ═══════════════════════════════════════════════════
布局完成后对所有节点做一次全局分离,确保不重叠。
与 pushAway 的区别:没有固定压力源,每对重叠节点各自向外移动一半,
适合初始布局、切换布局、添加/删除节点后的全局整理。
════════════════════════════════════════════════════════════════════════ */
function separateAll(){
const allIds = Object.keys(pos);
if(allIds.length < 2) return;
const hw = {}, hh = {};
allIds.forEach(id => {
const n = nodeMap[id] || TREE;
hw[id] = n._w/2 + DRAG_PAD/2;
hh[id] = n._h/2 + DRAG_PAD/2;
});
// 预处理:给完全重合的节点施加微小扰动,防止对称死锁
for(let i = 0; i < allIds.length; i++){
for(let j = i+1; j < allIds.length; j++){
const pi = pos[allIds[i]], pj = pos[allIds[j]];
if(!pi||!pj) continue;
if(Math.abs(pi.x-pj.x)<0.1 && Math.abs(pi.y-pj.y)<0.1){
// 按索引差给一个确定性的角度扰动,避免随机性
const angle = (j - i) * 2.399; // 黄金角,均匀分布
pj.x += Math.cos(angle) * 0.5;
pj.y += Math.sin(angle) * 0.5;
}
}
}
const SEP_ITER = allIds.length * 4; // 实测:n*4 覆盖 99% 的实际场景,50节点以内 <10ms
for(let iter = 0; iter < SEP_ITER; iter++){
let anyOverlap = false;
for(let i = 0; i < allIds.length; i++){
for(let j = i+1; j < allIds.length; j++){
const ai = allIds[i], aj = allIds[j];
const pi = pos[ai], pj = pos[aj];
if(!pi || !pj) continue;
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const overlapX = (hw[ai] + hw[aj]) - Math.abs(dx);
const overlapY = (hh[ai] + hh[aj]) - Math.abs(dy);
if(overlapX <= 0 || overlapY <= 0) continue;
anyOverlap = true;
const fixI = (nodeMap[ai]||TREE)._depth === 0;
const fixJ = (nodeMap[aj]||TREE)._depth === 0;
if(overlapX <= overlapY){
const push = overlapX * (dx >= 0 ? 1 : -1);
if(!fixI && !fixJ){ pi.x -= push*0.5; pj.x += push*0.5; }
else if(fixI) pj.x += push;
else pi.x -= push;
} else {
const push = overlapY * (dy >= 0 ? 1 : -1);
if(!fixI && !fixJ){ pi.y -= push*0.5; pj.y += push*0.5; }
else if(fixI) pj.y += push;
else pi.y -= push;
}
}
}
if(!anyOverlap) break;
}
// 同步 side / pinned 坐标
allIds.forEach(id => {
const p = pos[id]; if(!p) return;
const n = nodeMap[id] || TREE;
const parentP = p.parentId ? pos[p.parentId] : null;
if(parentP) p.side = p.x >= parentP.x ? 1 : -1;
if(n._pinned){ n._px = p.x; n._py = p.y; }
});
}
/* ══ INTERACTION ══ */
let activeOp=null, T={x:0,y:0,s:1};
function applyTransform(){
const t=`translate(T.x,T.y) scale(T.s)`;
document.getElementById("edges-g").setAttribute("transform",t);
document.getElementById("nodes-g").setAttribute("transform",t);
}
function svgXY(cx,cy){return{x:(cx-T.x)/T.s,y:(cy-T.y)/T.s};}
function startNodeDrag(e,node){const sv=svgXY(e.clientX,e.clientY);activeOp={type:"nodedrag",node,ox:sv.x-pos[node._id].x,oy:sv.y-pos[node._id].y,moved:false};}
function startResizeW(e,node){e.stopPropagation();const sv=svgXY(e.clientX,e.clientY);activeOp={type:"rw",node,sx:sv.x,sw:node._w};}
function startResizeH(e,node){e.stopPropagation();const sv=svgXY(e.clientX,e.clientY);activeOp={type:"rh",node,sy:sv.y,sh:node._h};}
window.addEventListener("mousemove",e=>{
if(!activeOp) return;
if(activeOp.type==="canvas"){T.x=e.clientX-activeOp.sx;T.y=e.clientY-activeOp.sy;applyTransform();return;}
if(activeOp.type==="nodedrag"){
const sv=svgXY(e.clientX,e.clientY);
if(!activeOp.moved&&Math.hypot(sv.x-pos[activeOp.node._id].x-activeOp.ox,sv.y-pos[activeOp.node._id].y-activeOp.oy)<2) return;
activeOp.moved=true;
const node=activeOp.node;
node._pinned=true; node._px=sv.x-activeOp.ox; node._py=sv.y-activeOp.oy;
pos[node._id].x=node._px; pos[node._id].y=node._py;
// 实时更新 side:节点在父节点哪侧由实际坐标决定
const _pp=pos[pos[node._id].parentId]; if(_pp) pos[node._id].side=pos[node._id].x>=_pp.x?1:-1;
patchNodeEl(node); refreshEdgesFor(node._id);
// rAF throttle: 每帧最多执行一次 pushAway,避免高频 mousemove 掉帧
if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(node._id);});}
return;
}
if(activeOp.type==="rw"){const sv=svgXY(e.clientX,e.clientY);activeOp.node._w=Math.max(MIN_W,activeOp.sw+(sv.x-activeOp.sx));patchNodeEl(activeOp.node);refreshEdgesFor(activeOp.node._id);if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(activeOp.node._id);});}return;}
if(activeOp.type==="rh"){const sv=svgXY(e.clientX,e.clientY);activeOp.node._h=Math.max(MIN_H,activeOp.sh+(sv.y-activeOp.sy));patchNodeEl(activeOp.node);refreshEdgesFor(activeOp.node._id);if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(activeOp.node._id);});}return;}
});
window.addEventListener("mouseup",()=>{
if(!activeOp) return;
if(activeOp.type==="nodedrag"){
if(!activeOp.moved){
const node=activeOp.node,kids=node.children||node.branches||[];
if(kids.length&&node._depth>0) toggle(node._id);
} else {
snapshotForUndo(); // 拖动结束后保存快照
}
}
if(activeOp.type==="rw"||activeOp.type==="rh") snapshotForUndo();
activeOp=null; wrap.style.cursor="";
});
const wrap=document.getElementById("wrap");
wrap.addEventListener("mousedown",e=>{
if(activeOp) return;
if(e.target===e.currentTarget||e.target.tagName==="svg"||["edges-g","nodes-g"].includes(e.target.id))
{selectNode(null);closeCtxMenu();}
activeOp={type:"canvas",sx:e.clientX-T.x,sy:e.clientY-T.y}; wrap.style.cursor="grabbing";
});
document.getElementById("nodes-g").addEventListener("mousedown",e=>{
const rt=e.target.dataset.resize,nid=e.target.dataset.id; if(!rt||!nid) return;
e.stopPropagation(); const node=nodeMap[nid]; if(!node) return;
if(rt==="w")startResizeW(e,node); if(rt==="h")startResizeH(e,node);
});
wrap.addEventListener("wheel",e=>{
e.preventDefault();
const r=wrap.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
const f=e.deltaY<0?1.11:0.9, ns=Math.min(Math.max(T.s*f,0.1),6);
T.x=mx-(ns/T.s)*(mx-T.x); T.y=my-(ns/T.s)*(my-T.y); T.s=ns; applyTransform();
},{passive:false});
let tDrag=null;
wrap.addEventListener("touchstart",e=>{if(e.touches.length===1)tDrag={sx:e.touches[0].clientX-T.x,sy:e.touches[0].clientY-T.y};},{passive:true});
wrap.addEventListener("touchmove",e=>{if(tDrag&&e.touches.length===1){T.x=e.touches[0].clientX-tDrag.sx;T.y=e.touches[0].clientY-tDrag.sy;applyTransform();}},{passive:true});
wrap.addEventListener("touchend",()=>tDrag=null);
/* ══ COLLAPSE ══ */
function toggle(id){const n=nodeMap[id];if(!n)return;n._collapsed=!n._collapsed;rebuild();}
function expandAll(){Object.values(nodeMap).forEach(n=>n._collapsed=false);rebuild();}
function collapseAll(){Object.values(nodeMap).forEach(n=>{if(n._depth>=1)n._collapsed=true;});rebuild();}
function rebuild(){
layout(TREE);
separateAll(); // 布局后全局分离,确保不重叠
renderAll(TREE);
}
function resetView(){T={x:wrap.clientWidth/2,y:wrap.clientHeight/2,s:1};applyTransform();}
function zoomIn(){T.s=Math.min(T.s*1.2,6);applyTransform();}
function zoomOut(){T.s=Math.max(T.s/1.2,0.1);applyTransform();}
/* ══ TOAST ══ */
function showToast(msg,dur=2400){const t=document.getElementById("toast");t.textContent=msg;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),dur);}
/* ══ EXPORT ══ */
function getBounds(){
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
[...Object.values(nodeMap),TREE].forEach(n=>{
const p=pos[n._id];if(!p)return;
x0=Math.min(x0,p.x-n._w/2);x1=Math.max(x1,p.x+n._w/2);
y0=Math.min(y0,p.y-n._h/2);y1=Math.max(y1,p.y+n._h/2);
});
const pad=60; return{x0:x0-pad,y0:y0-pad,w:x1-x0+pad*2,h:y1-y0+pad*2};
}
function buildExportSVG(){
const b=getBounds();
const clone=document.getElementById("svg").cloneNode(true);
clone.querySelectorAll(".rh,.rh-b,.tog,.sel-ring").forEach(e=>e.remove());
clone.querySelectorAll(".nd").forEach(g=>g.classList.remove("selected"));
clone.querySelectorAll("#edges-g,#nodes-g").forEach(g=>g.removeAttribute("transform"));
clone.setAttribute("viewBox",`b.x0 b.y0 b.w b.h`);
clone.setAttribute("width",Math.round(b.w)); clone.setAttribute("height",Math.round(b.h));
clone.style.cssText="";
const bg=document.createElementNS(SVG_NS,"rect");
bg.setAttribute("x",b.x0);bg.setAttribute("y",b.y0);bg.setAttribute("width",b.w);bg.setAttribute("height",b.h);bg.setAttribute("fill","#0d0f1a");
clone.insertBefore(bg,clone.firstChild);
const st=document.createElementNS(SVG_NS,"style");
st.textContent='text{font-family:-apple-system,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;}';
clone.insertBefore(st,clone.firstChild);
return{svgEl:clone,b};
}
function dlBlob(blob,name){
const url=URL.createObjectURL(blob),a=document.createElement("a");
a.href=url;a.download=name;document.body.appendChild(a);a.click();
setTimeout(()=>{document.body.removeChild(a);URL.revokeObjectURL(url);},300);
}
function exportAs(fmt){
showToast("正在导出 "+fmt.toUpperCase()+" …");
const{svgEl,b}=buildExportSVG();
const svgStr=new XMLSerializer().serializeToString(svgEl);
const safe=TITLE.replace(/[\\/:*?"<>|]/g,"_");
if(fmt==="svg"){dlBlob(new Blob([svgStr],{type:"image/svg+xml"}),safe+".svg");return;}
const sc=fmt==="jpg"?2:2.5;
const canvas=document.createElement("canvas"); canvas.width=b.w*sc; canvas.height=b.h*sc;
const ctx=canvas.getContext("2d");
if(fmt==="jpg"){ctx.fillStyle="#0d0f1a";ctx.fillRect(0,0,canvas.width,canvas.height);}
const img=new Image();
const bUrl=URL.createObjectURL(new Blob([svgStr],{type:"image/svg+xml"}));
img.onload=()=>{
ctx.drawImage(img,0,0,canvas.width,canvas.height); URL.revokeObjectURL(bUrl);
if(fmt==="png") canvas.toBlob(bl=>dlBlob(bl,safe+".png"),"image/png");
else if(fmt==="jpg") canvas.toBlob(bl=>dlBlob(bl,safe+".jpg"),"image/jpeg",0.93);
else if(fmt==="pdf") makePDF(canvas,b,safe);
};
img.src=bUrl;
}
function makePDF(canvas,b,safe){
const jData=canvas.toDataURL("image/jpeg",0.92).split(",")[1];
const jBytes=Uint8Array.from(atob(jData),c=>c.charCodeAt(0));
const W=Math.round(b.w),H=Math.round(b.h);
const enc=new TextEncoder();
function str(s){return enc.encode(s);}
const stream=`q W 0 0 H 0 0 cm /Im1 Do Q`;
const objs=[str("%PDF-1.4\n"),str("1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n"),
str(`2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n`),
str(`3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 W H]/Contents 4 0 R/Resources<</XObject<</Im1 5 0 R>>>>>>\nendobj\n`),
str(`4 0 obj\n<</Length stream.length>>\nstream\nstream\nendstream\nendobj\n`),
str(`5 0 obj\n<</Type/XObject/Subtype/Image/Width canvas.width/Height canvas.height/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/DCTDecode/Length jBytes.length>>\nstream\n`),
jBytes,str("\nendstream\nendobj\n"),
str("xref\n0 6\n0000000000 65535 f \ntrailer\n<</Size 6/Root 1 0 R>>\nstartxref\n0\n%%EOF\n")];
const total=objs.reduce((s,o)=>s+o.length,0); const buf=new Uint8Array(total); let off=0;
for(const o of objs){buf.set(o,off);off+=o.length;}
dlBlob(new Blob([buf],{type:"application/pdf"}),safe+".pdf");
}
/* ══ XMIND ══ */
function exportXmind(){
showToast("正在生成 XMind …");
// ── helpers ────────────────────────────────────────────────────────────
function uid(){ return crypto.randomUUID().replace(/-/g,"").slice(0,26); }
function xe(s){ return String(s).replace(/&/g,"&").replace(/</g,"<")
.replace(/>/g,">").replace(/"/g,"""); }
// ── content.json (XMind 2020+) ────────────────────────────────────────
function xnJson(node){
const kids=(node.branches||[]).concat(node.children||[]);
const o={id:uid(),class:"topic",title:node.label||node.central||""};
if(kids.length) o.children={attached:kids.map(xnJson)};
if(node.color) o.style={id:uid(),properties:{
"line-color":node.color,"background-color":node.color+"33",
"border-line-color":node.color,"line-width":"2pt",
"shape-class":"org.xmind.topicShape.roundedRect"}};
return o;
}
const rootJson=xnJson(TREE);
rootJson.structureClass="org.xmind.ui.map.unbalanced";
const contentJson=[{id:uid(),class:"sheet",title:TITLE,rootTopic:rootJson,theme:{},extensions:[]}];
// ── content.xml (XMind 8) ─────────────────────────────────────────────
function xnXml(node, depth){
const kids=(node.branches||[]).concat(node.children||[]);
const label=node.label||node.central||"";
const ind=" ".repeat(depth);
let s=`ind<topic id="uid()"`;
if(depth===0) s+=' structure-class="org.xmind.ui.map.unbalanced"';
s+=`>\nind <title>xe(label)</title>`;
if(kids.length){
s+=`\nind <children>\nind <topics type="attached">`;
for(const c of kids) s+="\n"+xnXml(c,depth+3);
s+=`\nind </topics>\nind </children>`;
}
s+=`\nind</topic>`;
return s;
}
const sheetId=uid();
const xmlRoot=xnXml(TREE,0);
const contentXml=`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n`+
`<xmap-content xmlns="urn:xmind:xmap:xmlns:content:2.0"\n`+
` xmlns:fo="http://www.w3.org/1999/XSL/Format"\n`+
` xmlns:svg="http://www.w3.org/2000/svg"\n`+
` xmlns:xhtml="http://www.w3.org/1999/xhtml"\n`+
` xmlns:xlink="http://www.w3.org/1999/xlink"\n`+
` version="2.0">\n`+
` <sheet id="sheetId">\n`+
xmlRoot+"\n"+
` <title>xe(TITLE)</title>\n`+
` </sheet>\n`+
`</xmap-content>`;
const stylesXml=`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n`+
`<xmap-styles xmlns="urn:xmind:xmap:xmlns:style:2.0" version="2.0"></xmap-styles>`;
// ── metadata & manifest ────────────────────────────────────────────────
const meta={modifier:"",created:new Date().toISOString().slice(0,19)+".000+0000",
creator:{name:"OpenClaw MindMap",version:"5.0",platform:""}};
const mf={"file-entries":{
"content.json":{"media-type":"application/json"},
"content.xml": {"media-type":"text/xml"},
"styles.xml": {"media-type":"text/xml"},
"metadata.json":{"media-type":"application/json"},
"manifest.json":{"media-type":"application/json"}}};
// ── ZIP builder ────────────────────────────────────────────────────────
function u16(v){const b=new Uint8Array(2);new DataView(b.buffer).setUint16(0,v,true);return b;}
function u32(v){const b=new Uint8Array(4);new DataView(b.buffer).setUint32(0,v,true);return b;}
function crc32(d){
if(!crc32.t){crc32.t=new Uint32Array(256);for(let i=0;i<256;i++){let c=i;for(let j=0;j<8;j++)c=c&1?0xEDB88320^(c>>>1):c>>>1;crc32.t[i]=c;}}
let c=0xFFFFFFFF;for(const b of d)c=crc32.t[(c^b)&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
}
function cat(...a){const t=a.reduce((s,x)=>s+x.length,0),o=new Uint8Array(t);let p=0;for(const x of a){o.set(x,p);p+=x.length;}return o;}
const enc=new TextEncoder();
const files=[
["manifest.json", enc.encode(JSON.stringify(mf,null,2))],
["content.json", enc.encode(JSON.stringify(contentJson,null,2))],
["content.xml", enc.encode(contentXml)],
["styles.xml", enc.encode(stylesXml)],
["metadata.json", enc.encode(JSON.stringify(meta,null,2))],
];
const lParts=[],cds=[];let dataOff=0;
for(const[name,data]of files){
const nb=enc.encode(name),crc=crc32(data),sz=data.length;
const lh=cat(new Uint8Array([0x50,0x4B,0x03,0x04]),u16(20),u16(0),u16(0),u16(0),u16(0),
u32(crc),u32(sz),u32(sz),u16(nb.length),u16(0),nb);
const cd=cat(new Uint8Array([0x50,0x4B,0x01,0x02]),u16(20),u16(20),u16(0),u16(0),u16(0),u16(0),
u32(crc),u32(sz),u32(sz),u16(nb.length),u16(0),u16(0),u16(0),u16(0),u32(0),u32(dataOff),nb);
lParts.push(lh,data);cds.push(cd);dataOff+=lh.length+sz;
}
const cdBytes=cat(...cds);
const eocd=cat(new Uint8Array([0x50,0x4B,0x05,0x06]),u16(0),u16(0),
u16(files.length),u16(files.length),u32(cdBytes.length),u32(dataOff),u16(0));
dlBlob(new Blob([cat(...lParts,cdBytes,eocd)],{type:"application/octet-stream"}),
TITLE.replace(/[\\/:*?"<>|]/g,"_")+".xmind");
}
/* ══ BOOT ══ */
RAW._depth=0; RAW.label=RAW.central;
annotate(RAW,0);
const TREE=RAW;
layout(TREE);
separateAll(); // 初始布局后分离
renderAll(TREE);
resetView();
</script>
</body>
</html>
FILE:examples/product_launch.json
{
"central": "产品发布 v2.0",
"branches": [
{
"label": "需求分析",
"color": "#4A90D9",
"children": [
{"label": "用户调研", "children": ["问卷设计", "用户访谈", "数据分析"]},
{"label": "竞品分析", "children": ["功能对比", "定价策略"]},
"需求优先级排序",
"PRD 文档"
]
},
{
"label": "设计阶段",
"color": "#1ABC9C",
"children": [
{"label": "UI 设计", "children": ["线框图", "视觉稿", "设计规范"]},
{"label": "UX 设计", "children": ["用户流程", "交互原型"]},
"设计评审",
"可用性测试"
]
},
{
"label": "研发阶段",
"color": "#E86C3A",
"children": [
{"label": "前端开发", "children": ["组件库", "页面实现", "性能优化"]},
{"label": "后端开发", "children": ["API 设计", "数据库", "微服务"]},
{"label": "测试", "children": ["单元测试", "集成测试", "压力测试"]},
"代码评审"
]
},
{
"label": "上线准备",
"color": "#9B59B6",
"children": [
{"label": "灰度发布", "children": ["内测用户", "A/B 测试"]},
{"label": "运维保障", "children": ["监控告警", "回滚预案"]},
"文档更新",
"客服培训"
]
},
{
"label": "推广运营",
"color": "#F39C12",
"children": [
{"label": "市场推广", "children": ["发布会", "媒体公关", "社交媒体"]},
{"label": "数据追踪", "children": ["DAU/MAU", "转化率", "留存率"]},
"用户反馈收集",
"迭代规划"
]
}
]
}
FILE:examples/python_learning.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Python 学习路径</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",
"Hiragino Sans GB","Microsoft YaHei",sans-serif;
background:#0d0f1a;color:#e8eaf0;
height:100vh;overflow:hidden;display:flex;flex-direction:column;
}
header{
padding:8px 16px;background:rgba(255,255,255,.04);
border-bottom:1px solid rgba(255,255,255,.07);
display:flex;flex-direction:column;gap:6px;
flex-shrink:0;user-select:none;
}
.header-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap;}
.header-row.top{justify-content:space-between;}
header h1{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;flex:1;min-width:0;}
.btn-group{
display:flex;align-items:center;gap:2px;
background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
border-radius:8px;padding:2px;
}
.btn-group-label{
font-size:10px;color:rgba(255,255,255,.28);padding:0 5px 0 7px;
white-space:nowrap;letter-spacing:.04em;text-transform:uppercase;
}
.btn{
background:transparent;border:1px solid transparent;
color:#c0c4dc;padding:4px 9px;border-radius:6px;font-size:12px;
cursor:pointer;transition:background .12s,color .12s,border-color .12s;
white-space:nowrap;
}
.btn:hover{background:rgba(255,255,255,.1);color:#fff;border-color:rgba(255,255,255,.13);}
.btn:active{background:rgba(255,255,255,.16);}
.btn.exp{
font-size:11px;padding:4px 10px;
color:rgba(180,200,255,.75);
background:rgba(55,85,200,.14);
border-color:rgba(90,130,255,.22);
}
.btn.exp:hover{background:rgba(75,110,230,.3);border-color:rgba(120,160,255,.4);color:#ccd8ff;}
.sep{width:1px;height:16px;background:rgba(255,255,255,.1);margin:0 2px;}
.btn.layout-btn{padding:3px 8px;font-size:11px;color:rgba(255,255,255,.5);}
.btn.layout-btn:hover{color:#e8eaf0;}
.btn.layout-btn.active{background:rgba(124,140,248,.28);border-color:rgba(124,140,248,.55);color:#b4bcff;font-weight:500;}
.btn.undo-btn{font-size:14px;padding:3px 7px;color:rgba(255,255,255,.38);}
.btn.undo-btn:not([disabled]):hover{color:#e8eaf0;}
.btn.undo-btn[disabled]{opacity:.28;cursor:default;}
.btn.undo-btn[disabled]:hover{background:transparent;border-color:transparent;}
.btn.undo-btn{padding:4px 8px;font-size:12px;}
.btn.undo-btn:disabled{opacity:.3;cursor:default;}
.meta{font-size:10px;color:rgba(255,255,255,.22);white-space:nowrap;margin-left:auto;}
#wrap{flex:1;overflow:hidden;position:relative;}
svg{width:100%;height:100%;display:block;}
.nd{cursor:pointer;}
.nd .bg{transition:filter .12s;}
.nd:hover .bg{filter:brightness(1.3);}
.nd.selected .sel-ring{display:block;}
.sel-ring{display:none;pointer-events:none;}
.rh {opacity:0;transition:opacity .15s;cursor:ew-resize;}
.rh-b{opacity:0;transition:opacity .15s;cursor:ns-resize;}
.nd:hover .rh,.nd:hover .rh-b{opacity:1;}
.tog circle{transition:fill .12s;}
.tog:hover circle{opacity:.9;}
.edge{fill:none;}
/* context menu */
#ctx-menu{
position:fixed;display:none;z-index:300;
background:rgba(18,22,38,.98);border:1px solid rgba(255,255,255,.13);
border-radius:9px;padding:5px 0;min-width:165px;
box-shadow:0 8px 32px rgba(0,0,0,.5);font-size:13px;
}
#ctx-menu.open{display:block;}
.ctx-item{padding:7px 16px;cursor:pointer;display:flex;align-items:center;gap:9px;color:#e0e4f0;transition:background .1s;user-select:none;}
.ctx-item:hover{background:rgba(255,255,255,.08);}
.ctx-item.danger{color:#f87171;}
.ctx-item.danger:hover{background:rgba(248,113,113,.1);}
.ctx-sep{height:1px;background:rgba(255,255,255,.08);margin:4px 0;}
.ctx-icon{width:16px;text-align:center;font-size:14px;}
.ctx-colors{padding:6px 12px;display:flex;gap:6px;flex-wrap:wrap;}
.color-dot{width:18px;height:18px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:transform .1s,border-color .1s;}
.color-dot.active{border-color:#fff;transform:scale(1.25);box-shadow:0 0 0 2px rgba(255,255,255,.3);}
.color-dot:hover{transform:scale(1.2);border-color:rgba(255,255,255,.5);}
/* toast */
#toast{
position:fixed;bottom:20px;left:50%;transform:translateX(-50%);
background:rgba(13,15,26,.97);border:1px solid rgba(255,255,255,.15);
border-radius:8px;padding:7px 18px;font-size:13px;
pointer-events:none;opacity:0;transition:opacity .2s;z-index:99;
}
#toast.show{opacity:1;}
</style>
</head>
<body>
<header>
<!-- 第一行:标题 + 导出 -->
<div class="header-row top">
<h1>🧠 Python 学习路径</h1>
<div class="btn-group">
<span class="btn-group-label">导出</span>
<button class="btn exp" onclick="exportAs('svg')" title="导出 SVG">SVG</button>
<button class="btn exp" onclick="exportAs('png')" title="导出 PNG">PNG</button>
<button class="btn exp" onclick="exportAs('jpg')" title="导出 JPG">JPG</button>
<button class="btn exp" onclick="exportAs('pdf')" title="导出 PDF">PDF</button>
<button class="btn exp" onclick="exportXmind()" title="导出 XMind">XMind</button>
</div>
</div>
<!-- 第二行:视图控制 + 撤销 + 布局 -->
<div class="header-row">
<div class="btn-group">
<span class="btn-group-label">视图</span>
<button class="btn" onclick="resetView()" title="重置视图">⊙</button>
<button class="btn" onclick="expandAll()" title="全部展开">⊞</button>
<button class="btn" onclick="collapseAll()" title="全部折叠">⊟</button>
<button class="btn" onclick="zoomIn()" title="放大">+</button>
<button class="btn" onclick="zoomOut()" title="缩小">-</button>
</div>
<div class="btn-group">
<span class="btn-group-label">历史</span>
<button class="btn undo-btn" id="undo-btn" onclick="undo()" title="撤销 (Ctrl+Z)" disabled>↶</button>
<button class="btn undo-btn" id="redo-btn" onclick="redo()" title="重做 (Ctrl+Y)" disabled>↷</button>
</div>
<div class="btn-group">
<span class="btn-group-label">布局</span>
<button class="btn layout-btn active" id="layout-btn-0" onclick="switchLayout(0)" title="左右均衡">⇆ 左右</button>
<button class="btn layout-btn" id="layout-btn-1" onclick="switchLayout(1)" title="全向辐射">✶ 辐射</button>
<button class="btn layout-btn" id="layout-btn-2" onclick="switchLayout(2)" title="向右树形">➡ 树形</button>
<button class="btn layout-btn" id="layout-btn-3" onclick="switchLayout(3)" title="垂直向下">🌳 垂直</button>
<button class="btn layout-btn" id="layout-btn-4" onclick="switchLayout(4)" title="力导向动画">⚡ 力导向</button>
<button class="btn layout-btn" id="layout-btn-5" onclick="switchLayout(5)" title="时间线">⏩ 时间线</button>
<button class="btn layout-btn" id="layout-btn-6" onclick="switchLayout(6)" title="鱼骨图">🐟 鱼骨</button>
<button class="btn layout-btn" id="layout-btn-7" onclick="switchLayout(7)" title="括弧图">} 括弧</button>
</div>
<span class="meta">右键菜单 · Tab 添加子节点 · Del 删除 · Ctrl+Z 撤销</span>
</div>
</header>
<div id="wrap">
<svg id="svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<linearGradient id="root-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4c5fdb"/>
<stop offset="100%" stop-color="#7c8cf8"/>
</linearGradient>
</defs>
<g id="edges-g"></g>
<g id="nodes-g"></g>
</svg>
</div>
<div id="ctx-menu">
<div class="ctx-item" onclick="ctxAction('add-child')"><span class="ctx-icon">+</span>添加子节点</div>
<div class="ctx-item" onclick="ctxAction('add-sibling')"><span class="ctx-icon">↵</span>添加兄弟节点</div>
<div class="ctx-sep"></div>
<div class="ctx-item" style="font-size:11px;color:rgba(255,255,255,.4);padding:4px 16px;cursor:default;" id="ctx-color-label">更改节点颜色</div>
<div class="ctx-colors" id="ctx-colors"></div>
<div class="ctx-sep"></div>
<div class="ctx-item danger" onclick="ctxAction('delete')"><span class="ctx-icon">🗑</span>删除节点</div>
</div>
<div id="toast"></div>
<script>
const RAW = {"central": "Python 学习路径", "branches": [{"label": "🌟 基础语法", "color": "#4A90D9", "children": [{"label": "数据类型", "children": [{"label": "int/float", "children": []}, {"label": "str", "children": []}, {"label": "list/tuple", "children": []}, {"label": "dict/set", "children": []}]}, {"label": "控制流", "children": [{"label": "if/elif/else", "children": []}, {"label": "for/while", "children": []}, {"label": "break/continue", "children": []}]}, {"label": "函数", "children": [{"label": "定义与调用", "children": []}, {"label": "参数类型", "children": []}, {"label": "lambda", "children": []}, {"label": "装饰器", "children": []}]}, {"label": "模块与包", "children": []}]}, {"label": "📎 面向对象", "color": "#27AE60", "children": [{"label": "类与对象", "children": [{"label": "属性", "children": []}, {"label": "方法", "children": []}, {"label": "__init__", "children": []}]}, {"label": "继承", "children": [{"label": "单继承", "children": []}, {"label": "多继承", "children": []}, {"label": "super()", "children": []}]}, {"label": "封装与多态", "children": []}, {"label": "魔术方法", "children": []}]}, {"label": "🔹 常用库", "color": "#E86C3A", "children": [{"label": "数据处理", "children": [{"label": "NumPy", "children": []}, {"label": "Pandas", "children": []}, {"label": "Polars", "children": []}]}, {"label": "可视化", "children": [{"label": "Matplotlib", "children": []}, {"label": "Seaborn", "children": []}, {"label": "Plotly", "children": []}]}, {"label": "Web 框架", "children": [{"label": "FastAPI", "children": []}, {"label": "Django", "children": []}, {"label": "Flask", "children": []}]}, {"label": "AI/ML", "children": [{"label": "PyTorch", "children": []}, {"label": "scikit-learn", "children": []}, {"label": "transformers", "children": []}]}]}, {"label": "🛠️ 工程实践", "color": "#9B59B6", "children": [{"label": "项目管理", "children": [{"label": "虚拟环境", "children": []}, {"label": "依赖管理", "children": []}, {"label": "pyproject.toml", "children": []}]}, {"label": "代码质量", "children": [{"label": "类型注解", "children": []}, {"label": "linting", "children": []}, {"label": "格式化", "children": []}]}, {"label": "测试", "children": [{"label": "pytest", "children": []}, {"label": "unittest", "children": []}, {"label": "mock", "children": []}]}, {"label": "CI/CD", "children": []}]}, {"label": "▪️ 进阶专题", "color": "#00BCD4", "children": [{"label": "并发编程", "children": [{"label": "多线程", "children": []}, {"label": "多进程", "children": []}, {"label": "asyncio", "children": []}]}, {"label": "性能优化", "children": [{"label": "Cython", "children": []}, {"label": "numba", "children": []}, {"label": "profiling", "children": []}]}, {"label": "元编程", "children": []}, {"label": "设计模式", "children": []}]}]};
const TITLE = "Python 学习路径";
const SVG_NS = "http://www.w3.org/2000/svg";
const CFG = [
{h:48,fs:16,fw:"700",rx:12,px:32,minW:180},
{h:38,fs:13,fw:"600",rx: 8,px:24,minW:110},
{h:30,fs:12,fw:"400",rx: 6,px:18,minW: 80},
{h:26,fs:11,fw:"400",rx: 5,px:16,minW: 72},
];
const PALETTE=["#4A90D9","#E86C3A","#27AE60","#9B59B6","#E74C3C","#F39C12","#1ABC9C","#E91E63","#00BCD4","#8BC34A"];
const H_GAP=[0,64,48,40], V_GAP=[0,20,12,8];
const MIN_W=60, MIN_H=20, HW=7;
let nodeMap={}, _nid=0, selectedId=null, ctxTargetId=null;
let _pushScheduled=false; // rAF throttle for pushAway
let _undoStack=[], _redoStack=[]; // undo/redo snapshot stacks
const MAX_UNDO=50;
function measureW(text,depth){
const c=CFG[Math.min(depth,CFG.length-1)];
let w=0; for(const ch of String(text)) w+=ch.charCodeAt(0)>127?c.fs*0.92:c.fs*0.58;
return Math.max(c.minW,w+c.px*2);
}
function annotate(node, depth, branchColor){
node._id=node._id||"n"+(++_nid); node._depth=depth;
if(node._collapsed===undefined) node._collapsed=false;
node._pinned=node._pinned||false;
if(node._px===undefined) node._px=null;
if(node._py===undefined) node._py=null;
node._w=node._w||measureW(node.label||node.central||"",depth);
node._h=node._h||CFG[Math.min(depth,CFG.length-1)].h;
// 缓存所属分支的主题色,O(1) 查色,避免 nodeColor 每帧递归
if(depth===0) node._branchColor=null;
else if(depth===1) node._branchColor=node.color||null;
else node._branchColor=branchColor||null;
nodeMap[node._id]=node;
const bc = depth===1 ? (node.color||null) : branchColor||null;
(node.children||[]).forEach(ch=>annotate(ch,depth+1,bc));
(node.branches||[]).forEach(b=>annotate(b,1,null));
}
function visKids(node){return node._collapsed?[]:(node.children||[]);}
function newId(){_nid++;let id="n"+_nid;while(nodeMap[id]){_nid++;id="n"+_nid;}return id;}
let pos={};
let currentLayout=0; // 0=左右均衡 1=辐射 2=向右树 3=垂直树 4=力导向
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 0 — 左右均衡树(默认)
分支均分左右,每侧垂直树形,S曲线+直角折线
══════════════════════════════════════════════════════════════════════════ */
function subtreeH(node){
const vg=V_GAP[Math.min(node._depth,V_GAP.length-1)];
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h,kids.reduce((s,k)=>s+subtreeH(k),0)+vg*(kids.length-1));
}
function layoutSubtree(node,cx,cy,side){
pos[node._id]={x:cx,y:cy,parentId:pos[node._id]?.parentId,side};
const kids=visKids(node); if(!kids.length) return;
const vg=V_GAP[Math.min(node._depth+1,V_GAP.length-1)];
const hg=H_GAP[Math.min(node._depth+1,H_GAP.length-1)];
const maxCW=Math.max(...kids.map(k=>k._w));
const childCX=cx+side*(node._w/2+hg+maxCW/2);
const heights=kids.map(k=>subtreeH(k));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(kids.length-1);
let curY=cy-totalH/2;
kids.forEach((kid,i)=>{
const kcy=curY+heights[i]/2;
pos[kid._id]={x:childCX,y:kcy,parentId:node._id,side};
layoutSubtree(kid,childCX,kcy,side);
curY+=heights[i]+vg;
});
}
function layout0(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:0};
const branches=tree.branches||[]; if(!branches.length) return;
const nRight=Math.ceil(branches.length/2);
function placeSide(brs,side){
if(!brs.length) return;
const maxBW=Math.max(...brs.map(b=>b._w));
const branchCX=side*(tree._w/2+H_GAP[1]+maxBW/2);
const heights=brs.map(b=>subtreeH(b));
const totalH=heights.reduce((a,b)=>a+b,0)+V_GAP[1]*(brs.length-1);
let curY=-totalH/2;
brs.forEach((b,i)=>{
const bcy=curY+heights[i]/2;
pos[b._id]={x:branchCX,y:bcy,parentId:tree._id,side:side};
layoutSubtree(b,branchCX,bcy,side);
curY+=heights[i]+V_GAP[1];
});
}
placeSide(branches.slice(0,nRight),1);
placeSide(branches.slice(nRight),-1);
}
function edgePath0(px,py,pw,cx,cy,cw,depth,side){
/* 左右均衡布局:全程三次贝塞尔,从节点侧边水平切出/切入
控制点在水平中点,产生优雅的 S 形曲线 */
const dx = cx - px;
const s = dx >= 0 ? 1 : -1; // 实际方向
const x1 = px + s * pw/2; // 父节点出口(侧边中心)
const x2 = cx - s * cw/2; // 子节点入口(侧边中心)
// 控制点张力:depth=1 用 0.5(标准 S 曲线),深层略收紧
const t = depth === 1 ? 0.5 : 0.45;
const cpx = x1 + (x2 - x1) * t;
// 节点几乎水平对齐时退化为直线
if(Math.abs(cy - py) < 3) return `Mx1,py Lx2,cy`;
return `Mx1,py Ccpx,py cpx,cy x2,cy`;
}
function edgePath1(px,py,cx,cy,depth){
/* 辐射布局:从父节点中心到子节点中心,沿径向方向平滑贝塞尔
控制点在各自 y 保持,让线条沿水平/垂直方向自然流出 */
const mx = (px+cx)/2, my = (py+cy)/2;
const t = depth === 1 ? 0.5 : 0.42;
const cp1x = px+(cx-px)*t, cp2x = cx-(cx-px)*t;
return `Mpx,py Ccp1x,py cp2x,cy cx,cy`;
}
function edgePath2(px,py,pw,cx,cy,cw,depth){
/* 树形(向右):从父节点右边出发,到子节点左边进入,圆角肘形
保留视觉上的流程感,同时用贝塞尔圆滑转角 */
const x1 = px + pw/2; // 父右边
const x2 = cx - cw/2; // 子左边
const mid = x1 + (x2 - x1) * 0.5;
if(Math.abs(cy - py) < 3) return `Mx1,py Lx2,cy`;
// 三次贝塞尔:水平出 → 水平入,中点弯曲
return `Mx1,py Cmid,py mid,cy x2,cy`;
}
function edgePath3(px,py,pw,cx,cy,cw,depth){
/* 垂直树:中心到中心,控制点保持各自 x,产生垂直 S 曲线 */
const my = (py + cy) / 2;
if(Math.abs(cx - px) < 3) return `Mpx,py Lcx,cy`;
return `Mpx,py Cpx,my cx,my cx,cy`;
}
function edgePath4(px,py,cx,cy){
/* 力导向:点到点平滑贝塞尔,控制点在中点 */
const mx = (px+cx)/2, my = (py+cy)/2;
const dx = cx-px, dy = cy-py, len = Math.sqrt(dx*dx+dy*dy)||1;
const perp = Math.min(len*0.12, 24);
const nx = -dy/len*perp, ny = dx/len*perp;
return `Mpx,py Qmx+nx,my+ny cx,cy`;
}
function drawSpine3(){
// 垂直树不需要装饰线,清除旧环形圈
document.querySelectorAll(".circ-ring").forEach(e => e.remove());
document.getElementById("fishbone-spine")?.remove();
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 1 — 全辐射(圆形散射)
══════════════════════════════════════════════════════════════════════════ */
function layout1(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:1};
const branches=tree.branches||[]; if(!branches.length) return;
function leafCount(node){
const kids=visKids(node);
return kids.length?kids.reduce((s,k)=>s+leafCount(k),0):1;
}
const totalLeaves=branches.reduce((s,b)=>s+leafCount(b),0);
const R1=Math.max(180, tree._w/2+120);
const R2=120;
let angle=-Math.PI/2;
branches.forEach(branch=>{
const frac=leafCount(branch)/totalLeaves;
const span=frac*Math.PI*2;
const mid=angle+span/2;
angle+=span;
const side=Math.cos(mid)>=0?1:-1;
const bx=Math.cos(mid)*R1, by=Math.sin(mid)*R1;
pos[branch._id]={x:bx,y:by,parentId:tree._id,side,angle:mid};
const kids=visKids(branch);
if(!kids.length) return;
const fanSpan=Math.min(span*.8, Math.PI*.6);
const fanStart=mid-fanSpan/2;
kids.forEach((kid,i)=>{
const ka=fanStart+(fanSpan*i)/(Math.max(kids.length-1,1))||mid;
const kR=R1+R2+kid._w/2;
const kx=Math.cos(ka)*kR, ky=Math.sin(ka)*kR;
pos[kid._id]={x:kx,y:ky,parentId:branch._id,side:Math.cos(ka)>=0?1:-1,angle:ka};
const gkids=visKids(kid);
if(!gkids.length) return;
const gFan=Math.min(fanSpan/(kids.length||1)*.9, Math.PI*.3);
gkids.forEach((gk,j)=>{
const ga=ka+(j-(gkids.length-1)/2)*gFan/(Math.max(gkids.length-1,1)||1);
const gR=kR+R2*.7+gk._w/2;
pos[gk._id]={x:Math.cos(ga)*gR,y:Math.sin(ga)*gR,parentId:kid._id,side:Math.cos(ga)>=0?1:-1,angle:ga};
});
});
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 2 — 向右树(Org Chart)
══════════════════════════════════════════════════════════════════════════ */
function subtreeH2(node){
const vg=Math.max(V_GAP[Math.min(node._depth,V_GAP.length-1)],14);
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h,kids.reduce((s,k)=>s+subtreeH2(k),0)+vg*(kids.length-1));
}
function layoutSubtree2(node,lx,cy){
const kids=visKids(node); if(!kids.length) return;
const hg=H_GAP[Math.min(node._depth+1,H_GAP.length-1)]+8;
const vg=Math.max(V_GAP[Math.min(node._depth+1,V_GAP.length-1)],14);
const maxCW=Math.max(...kids.map(k=>k._w));
const childLX=lx+node._w+hg;
const childCX=childLX+maxCW/2;
const heights=kids.map(k=>subtreeH2(k));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(kids.length-1);
let curY=cy-totalH/2;
kids.forEach((kid,i)=>{
const kcy=curY+heights[i]/2;
pos[kid._id]={x:childCX,y:kcy,parentId:node._id,side:1};
layoutSubtree2(kid,childLX,kcy);
curY+=heights[i]+vg;
});
}
function layout2(tree){
pos={};
pos[tree._id]={x:0,y:0,parentId:null,side:1};
const branches=tree.branches||[]; if(!branches.length) return;
const hg=H_GAP[1]+8;
const vg=Math.max(V_GAP[1],14);
const maxBW=Math.max(...branches.map(b=>b._w));
const branchLX=tree._w/2+hg;
const branchCX=branchLX+maxBW/2;
const heights=branches.map(b=>subtreeH2(b));
const totalH=heights.reduce((a,b)=>a+b,0)+vg*(branches.length-1);
let curY=-totalH/2;
branches.forEach((b,i)=>{
const bcy=curY+heights[i]/2;
pos[b._id]={x:branchCX,y:bcy,parentId:tree._id,side:1};
layoutSubtree2(b,branchLX,bcy);
curY+=heights[i]+vg;
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 3 — 垂直树(Vertical Tree / Top-Down)
══════════════════════════════════════════════════════════════════════════ */
function subtreeW3(node){
const hg=20;
const kids=visKids(node);
if(!kids.length) return node._w;
const childrenW=kids.reduce((s,k)=>s+subtreeW3(k),0)+hg*(kids.length-1);
return Math.max(node._w,childrenW);
}
function placeSubtree3(node,cx,top,parentId){
const cy=top+node._h/2;
pos[node._id]={x:cx,y:cy,parentId,side:1};
const kids=visKids(node); if(!kids.length) return;
const V_STEP=80, H_GAP_3=20;
const childTop=top+node._h+V_STEP;
const widths=kids.map(k=>subtreeW3(k));
const totalW=widths.reduce((a,b)=>a+b,0)+H_GAP_3*(kids.length-1);
let curX=cx-totalW/2;
kids.forEach((kid,i)=>{
const kidCX=curX+widths[i]/2;
placeSubtree3(kid,kidCX,childTop,node._id);
curX+=widths[i]+H_GAP_3;
});
}
function layout3(tree){
pos={};
const branches=tree.branches||[];
pos[tree._id]={x:0,y:0,parentId:null,side:1};
if(!branches.length) return;
const V_STEP=80, H_GAP_3=20;
const top1=tree._h/2+V_STEP;
const widths=branches.map(b=>subtreeW3(b));
const totalW=widths.reduce((a,b)=>a+b,0)+H_GAP_3*(branches.length-1);
let curX=-totalW/2;
branches.forEach((branch,i)=>{
const bx=curX+widths[i]/2;
placeSubtree3(branch,bx,top1,tree._id);
curX+=widths[i]+H_GAP_3;
});
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 4 — 力导向(Force-Directed)
· Coulomb 斥力 + Hooke 弹簧 + Verlet 积分
· 根节点固定在中心,其余节点自由浮动
· 120 帧动画后定格
══════════════════════════════════════════════════════════════════════════ */
let _fdRunning = false;
let _fdTimer = null;
function layout4(tree){
stopFD();
// ── 1. 收集所有节点(全树,不管 collapsed)────────────────────────────
const all = [];
;(function walk(node){
all.push(node);
(node._depth===0 ? (node.branches||[]) : visKids(node)).forEach(walk);
})(tree);
// ── 2. 从树结构建边(不依赖 pos,避免 stale 问题)─────────────────────
const edges = [];
;(function walkE(node){
const kids = node._depth===0 ? (node.branches||[]) : visKids(node);
kids.forEach(kid=>{ edges.push([node._id, kid._id]); walkE(kid); });
})(tree);
// ── 3. 先用 layout0 给一个合理初始骨架,再叠加力导向 ─────────────────
layout0(tree);
// ── 4. 给 pos 里没有的节点(collapsed)补一个随机初始位置 ───────────────
const seed = () => (Math.random()-0.5)*80;
all.forEach(node=>{
if(!pos[node._id]){
// 找父节点位置作为起点
const parentId = (node._depth===0) ? null
: edges.find(([a,b])=>b===node._id)?.[0] ?? null;
const pp = parentId ? pos[parentId] : null;
pos[node._id] = {
x: (pp ? pp.x : 0) + seed(),
y: (pp ? pp.y : 0) + seed(),
parentId, side:1, vx:0, vy:0
};
} else {
pos[node._id].vx = 0;
pos[node._id].vy = 0;
}
});
// ── 5. 力导向迭代 ────────────────────────────────────────────────────
const K_REPEL = 30000;
const K_SPRING = 0.09;
const DAMPING = 0.80;
const MAX_V = 55;
const FRAMES = 130;
function idealLen(depthA, depthB){ return 150 + Math.max(depthA,depthB)*35; }
let frame = 0;
function tick(){
if(!_fdRunning || currentLayout!==4){ _fdRunning=false; return; }
frame++;
const cool = Math.max(0.04, 1 - frame/FRAMES);
const fx={}, fy={};
all.forEach(n=>{ fx[n._id]=0; fy[n._id]=0; });
// Coulomb 斥力(所有节点对)
for(let i=0;i<all.length;i++){
const a=all[i], pa=pos[a._id];
if(!pa) continue;
for(let j=i+1;j<all.length;j++){
const b=all[j], pb=pos[b._id];
if(!pb) continue;
let dx=pb.x-pa.x, dy=pb.y-pa.y;
const dist2=dx*dx+dy*dy||0.01;
const dist=Math.sqrt(dist2);
// 额外排斥:节点尺寸内强推
const minD=(a._w+b._w)*0.5+24;
const f=K_REPEL/dist2*cool;
const push=dist<minD?(minD-dist)*1.2:0;
const ux=dx/dist, uy=dy/dist;
fx[a._id]-=(f+push)*ux; fy[a._id]-=(f+push)*uy;
fx[b._id]+=(f+push)*ux; fy[b._id]+=(f+push)*uy;
}
}
// Hooke 弹簧引力(有边的节点对)
edges.forEach(([aid,bid])=>{
const pa=pos[aid], pb=pos[bid];
if(!pa||!pb) return;
const dx=pb.x-pa.x, dy=pb.y-pa.y;
const dist=Math.sqrt(dx*dx+dy*dy)||0.01;
const na=nodeMap[aid]||{_depth:0}, nb=nodeMap[bid]||{_depth:1};
const target=idealLen(na._depth, nb._depth);
const stretch=(dist-target)*K_SPRING*cool;
const ux=dx/dist, uy=dy/dist;
if(aid!==tree._id){ fx[aid]+=stretch*ux; fy[aid]+=stretch*uy; }
fx[bid]-=stretch*ux; fy[bid]-=stretch*uy;
});
// 弱中心引力(防止整体漂移)
all.forEach(node=>{
if(node._id===tree._id) return;
const p=pos[node._id]; if(!p) return;
fx[node._id]-=p.x*0.006*cool;
fy[node._id]-=p.y*0.006*cool;
});
// 更新速度和位置
all.forEach(node=>{
if(node._id===tree._id) return;
const p=pos[node._id]; if(!p) return;
p.vx=(p.vx+fx[node._id])*DAMPING;
p.vy=(p.vy+fy[node._id])*DAMPING;
const spd=Math.sqrt(p.vx*p.vx+p.vy*p.vy)||1;
if(spd>MAX_V){ p.vx=p.vx/spd*MAX_V; p.vy=p.vy/spd*MAX_V; }
p.x+=p.vx; p.y+=p.vy;
p.side=p.x>=0?1:-1;
});
renderAll(tree);
if(frame<FRAMES){
_fdTimer=requestAnimationFrame(tick);
} else {
_fdRunning=false;
}
}
_fdRunning=true;
frame=0;
_fdTimer=requestAnimationFrame(tick);
}
function stopFD(){
_fdRunning = false;
if(_fdTimer){ cancelAnimationFrame(_fdTimer); _fdTimer=null; }
}
function edgePath4(px,py,cx,cy){
// 力导向用平滑曲线
const mx=(px+cx)/2, my=(py+cy)/2;
const dx=cx-px, dy=cy-py, len=Math.sqrt(dx*dx+dy*dy)||1;
// 控制点:垂直于连线方向偏移,形成弧线
const perp = Math.min(len*0.15, 30);
const nx=-dy/len*perp, ny=dx/len*perp;
return `Mpx,py Qmx+nx,my+ny cx,cy`;
}
function drawSpine3(){
document.querySelectorAll(".circ-ring,.fd-ring").forEach(e=>e.remove());
document.getElementById("fishbone-spine")?.remove();
document.getElementById("timeline-axis")?.remove();
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 5 — 时间线(Timeline / 水平流程)
· 中心节点在最左侧
· 主分支从左到右等间距水平排列
· 子节点垂直向下展开
· 主分支之间有水平时间轴主干线
══════════════════════════════════════════════════════════════════════════ */
function subtreeH5(node){
const vg=12;
const kids=visKids(node); if(!kids.length) return node._h;
return node._h + 60 + kids.reduce((s,k)=>s+k._h+vg,0) - vg;
}
function layout5(tree){
pos={};
const branches=tree.branches||[];
// root at far left
pos[tree._id]={x:0, y:0, parentId:null, side:1};
if(!branches.length) return;
const H_STEP = 220; // horizontal distance between branch columns
const V_TOP = 90; // vertical distance from timeline axis to first child
// Place branches horizontally
branches.forEach((b,i)=>{
const bx = tree._w/2 + H_STEP * (i+1);
pos[b._id]={x:bx, y:0, parentId:tree._id, side:1};
// Children stacked vertically below
const kids=visKids(b); if(!kids.length) return;
let curY = V_TOP;
kids.forEach(kid=>{
pos[kid._id]={x:bx, y:curY, parentId:b._id, side:1};
// Grandchildren further right
const gkids=visKids(kid); if(!gkids.length){ curY+=kid._h+12; return; }
let gy=curY;
gkids.forEach(gk=>{
pos[gk._id]={x:bx+kid._w/2+80+gk._w/2, y:gy, parentId:kid._id, side:1};
gy+=gk._h+8;
});
curY=Math.max(curY+kid._h+12, gy);
});
});
}
function edgePath5(px,py,pw,cx,cy,cw,depth){
if(depth===1){
// Timeline axis: horizontal straight line
const x1=px+pw/2, x2=cx-cw/2;
return `Mx1,py Lx2,cy`;
}
// Branch to children: vertical drop then horizontal
if(Math.abs(cx-px)<3){
// Straight down
const y1=py+20, y2=cy-cw/4;
return `Mpx,y1 Lcx,y2`;
}
// Horizontal bezier for grandchildren
const x1=px+pw/2, x2=cx-cw/2;
const mid=(x1+x2)/2;
if(Math.abs(cy-py)<3) return `Mx1,py Lx2,cy`;
return `Mx1,py Cmid,py mid,cy x2,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 6 — 鱼骨图(Fishbone / Ishikawa)
· 中心节点(鱼头)在右侧
· 水平主干(鱼脊)从右向左延伸
· 主分支交替从上下两侧 45° 斜向伸出(鱼骨)
· 子节点沿鱼骨方向排列
══════════════════════════════════════════════════════════════════════════ */
function layout6(tree){
pos={};
const branches=tree.branches||[];
// Fish head (root) on the right
pos[tree._id]={x:0, y:0, parentId:null, side:1};
if(!branches.length) return;
const SPINE_STEP = 180; // distance between bones along spine
const BONE_LEN = 130; // length of each bone (diagonal)
const SUB_GAP = 36; // gap between sub-nodes along bone
const ANGLE = Math.PI * 0.38; // ~68° from horizontal
branches.forEach((b,i)=>{
const spineX = -(tree._w/2 + 80 + SPINE_STEP * i);
const upDown = (i % 2 === 0) ? -1 : 1; // alternate up/down
const bx = spineX - Math.cos(ANGLE) * BONE_LEN;
const by = upDown * Math.sin(ANGLE) * BONE_LEN;
pos[b._id]={x:bx, y:by, parentId:tree._id, side:-1, _spineX:spineX};
// Children along the bone direction
const kids=visKids(b); if(!kids.length) return;
const dx = Math.cos(ANGLE) * SUB_GAP * upDown * 0;
const dirX = -Math.cos(ANGLE);
const dirY = upDown * Math.sin(ANGLE);
kids.forEach((kid,j)=>{
const dist = SUB_GAP * (j+1) + kid._w/2;
const kx = bx + dirX * dist * 0.3;
const ky = by + dirY * dist;
pos[kid._id]={x:kx, y:ky, parentId:b._id, side:-1};
// Grandchildren
const gkids=visKids(kid); if(!gkids.length) return;
gkids.forEach((gk,gi)=>{
pos[gk._id]={
x: kx - gk._w/2 - kid._w/2 - 30,
y: ky + (gi - (gkids.length-1)/2) * (gk._h + 6),
parentId:kid._id, side:-1
};
});
});
});
}
function edgePath6(px,py,pw,cx,cy,cw,depth,node){
if(depth===1){
// Bone: from spine attachment point to branch node
const spineX = pos[node?._id]?._spineX;
if(spineX !== undefined){
// Draw: spine point → branch node
return `MspineX,py Lcx,cy`;
}
return `Mpx,py Lcx,cy`;
}
// Sub-bones: straight lines
return `Mpx,py Lcx,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT 7 — 括弧图(Brace Map / 层级分解)
· 中心节点在最左侧
· 父节点与子节点之间绘制 SVG 大括号 "}"
· 大括号的尖端对准父节点右侧,两端包裹所有子节点
· 强调 整体 → { 部分1, 部分2, ... } 的分解关系
══════════════════════════════════════════════════════════════════════════ */
function subtreeH7(node){
const vg=16;
const kids=visKids(node); if(!kids.length) return node._h;
return Math.max(node._h, kids.reduce((s,k)=>s+subtreeH7(k),0)+vg*(kids.length-1));
}
function layout7(tree){
pos={};
pos[tree._id]={x:0, y:0, parentId:null, side:1};
const branches=tree.branches||[]; if(!branches.length) return;
const BRACE_W = 40; // width of the brace symbol area
const H_GAP_7 = 36; // gap between parent right edge and brace
const H_GAP_C = 20; // gap between brace and children left edge
function placeChildren(node, nodeRightX, cy){
const kids=visKids(node); if(!kids.length) return;
const vg=16;
const maxCW = Math.max(...kids.map(k=>k._w));
const childLX = nodeRightX + H_GAP_7 + BRACE_W + H_GAP_C;
const childCX = childLX + maxCW/2;
const heights = kids.map(k=>subtreeH7(k));
const totalH = heights.reduce((a,b)=>a+b,0) + vg*(kids.length-1);
let curY = cy - totalH/2;
kids.forEach((kid,i)=>{
const kcy = curY + heights[i]/2;
pos[kid._id]={x:childCX, y:kcy, parentId:node._id, side:1};
placeChildren(kid, childLX + maxCW/2, kcy);
curY += heights[i] + vg;
});
}
const maxBW = Math.max(...branches.map(b=>b._w));
const branchLX = tree._w/2 + H_GAP_7 + BRACE_W + H_GAP_C;
const branchCX = branchLX + maxBW/2;
const heights = branches.map(b=>subtreeH7(b));
const totalH = heights.reduce((a,b)=>a+b,0) + 16*(branches.length-1);
let curY = -totalH/2;
branches.forEach((b,i)=>{
const bcy = curY + heights[i]/2;
pos[b._id]={x:branchCX, y:bcy, parentId:tree._id, side:1};
placeChildren(b, branchLX + maxBW/2, bcy);
curY += heights[i] + 16;
});
}
function edgePath7(px,py,pw,cx,cy,cw,depth){
/* Brace Map edge: smooth cubic Bezier with a visible "step" shape.
Unlike tree layout's S-curve (which goes directly from parent to child),
the brace path goes: parent → horizontal exit → step down/up → horizontal enter → child
This creates the visual "}" bracket grouping effect.
Uses only C (cubic bezier) commands — no Q or L — for clean anti-aliased rendering.
*/
const x1 = px + pw/2; // parent right edge
const x2 = cx - cw/2; // child left edge
const midX = x1 + (x2 - x1) * 0.42; // vertical transit x
// Same height → simple S-curve
if(Math.abs(cy - py) < 4){
const cp = x1 + (x2 - x1) * 0.5;
return `Mx1,py Ccp,py cp,cy x2,cy`;
}
// Two-segment cubic bezier: parent→midpoint, midpoint→child
// Segment 1: horizontal exit from parent, curve down/up to midX
// Segment 2: from midX, curve horizontally into child
return `Mx1,py CmidX,py midX,py midX,(py+cy)/2 `
+ `CmidX,cy midX,cy x2,cy`;
}
/* ══════════════════════════════════════════════════════════════════════════
LAYOUT DISPATCHER
══════════════════════════════════════════════════════════════════════════ */
function layout(tree){
if(currentLayout===4){ layout4(tree); return; }
stopFD();
if(currentLayout===0) layout0(tree);
else if(currentLayout===1) layout1(tree);
else if(currentLayout===2) layout2(tree);
else if(currentLayout===3) layout3(tree);
else if(currentLayout===5) layout5(tree);
else if(currentLayout===6) layout6(tree);
else if(currentLayout===7) layout7(tree);
}
function edgePath(px,py,pw,cx,cy,cw,depth,side){
if(currentLayout===0) return edgePath0(px,py,pw,cx,cy,cw,depth,side);
if(currentLayout===1) return edgePath1(px,py,cx,cy,depth);
if(currentLayout===2) return edgePath2(px,py,pw,cx,cy,cw,depth);
if(currentLayout===3) return edgePath3(px,py,pw,cx,cy,cw,depth);
if(currentLayout===4) return edgePath4(px,py,cx,cy);
if(currentLayout===5) return edgePath5(px,py,pw,cx,cy,cw,depth);
if(currentLayout===6) return edgePath6(px,py,pw,cx,cy,cw,depth);
if(currentLayout===7) return edgePath7(px,py,pw,cx,cy,cw,depth);
return edgePath0(px,py,pw,cx,cy,cw,depth,side);
}
function switchLayout(n){
stopFD();
currentLayout=n;
Object.values(nodeMap).forEach(node=>{node._pinned=false;node._px=null;node._py=null;});
for(let i=0;i<8;i++){
const btn=document.getElementById("layout-btn-"+i);
if(btn) btn.classList.toggle("active",i===n);
}
rebuild();
resetView();
}
function nodeColor(node){
if(node.color) return node.color;
// 使用 annotate 时缓存的分支颜色,O(1) 查找
if(node._branchColor) return node._branchColor;
return "#888";
}
function el(tag,attrs){
const e=document.createElementNS(SVG_NS,tag);
if(attrs) for(const[k,v]of Object.entries(attrs)) e.setAttribute(k,v);
return e;
}
let edgeEls={}, nodeEls={};
function renderAll(tree){
document.getElementById("nodes-g").innerHTML="";
document.getElementById("edges-g").innerHTML="";
edgeEls={}; nodeEls={};
const all=[],q=[tree];
while(q.length){const n=q.shift();all.push(n);(n._depth===0?(n.branches||[]):visKids(n)).forEach(c=>q.push(c));}
const eg=document.getElementById("edges-g");
all.forEach(node=>{
const p=pos[node._id]; if(!p||p.parentId==null) return;
const pp=pos[p.parentId]; if(!pp) return;
const pNode=nodeMap[p.parentId]||tree, color=nodeColor(node), depth=node._depth, side=p.side||1;
// 线宽和透明度随深度自然收细,产生视觉层次感
const sw = depth===1 ? 2.2 : depth===2 ? 1.5 : 1.1;
const so = depth===1 ? 0.80 : depth===2 ? 0.55 : 0.38;
const path=el("path",{class:"edge",stroke:color,
"stroke-width":sw,
"stroke-opacity":so,
"stroke-linecap":"round","stroke-linejoin":"round","data-nid":node._id});
path.setAttribute("d",edgePath(pp.x,pp.y,pNode._w,p.x,p.y,node._w,depth,side,node._id));
if(pNode._collapsed) path.style.display="none";
eg.appendChild(path); edgeEls[node._id]=path;
});
const ng=document.getElementById("nodes-g");
all.forEach(node=>renderNode(node,ng));
if(currentLayout===3) drawSpine3();
applyTransform();
}
function refreshEdgesFor(id){
const p=pos[id]; if(!p) return;
const node=nodeMap[id]||TREE, path=edgeEls[id];
// 更新到父节点的边
if(path&&p.parentId!=null){
const pp=pos[p.parentId],pN=nodeMap[p.parentId]||TREE;
if(pp) path.setAttribute("d",edgePath(pp.x,pp.y,pN._w,p.x,p.y,node._w,node._depth,p.side||1));
}
// 只更新直接子节点的边(不再深度递归),用 visKids 跳过折叠子树
const kids=node._depth===0?(node.branches||[]):visKids(node);
kids.forEach(kid=>{
const cp=pos[kid._id],cp2=edgeEls[kid._id];
if(cp&&cp2) cp2.setAttribute("d",edgePath(p.x,p.y,node._w,cp.x,cp.y,kid._w,kid._depth,cp.side||1,kid._id));
// 继续向下更新(子节点位置没变,但父位置变了,所以子节点的边起点也变了)
refreshEdgesFor(kid._id);
});
}
function renderNode(node,g){
const p=pos[node._id]; if(!p) return;
const depth=node._depth, c=CFG[Math.min(depth,CFG.length-1)];
const w=node._w, h=node._h, color=nodeColor(node);
const label=node.label||node.central||"";
const kids=node.children||node.branches||[];
const grp=el("g",{class:"nd"+(node._id===selectedId?" selected":""),"data-id":node._id,
transform:`translate(p.x-w/2,p.y-h/2)`});
// selection ring
grp.appendChild(el("rect",{class:"sel-ring",x:-3,y:-3,width:w+6,height:h+6,
rx:c.rx+3,fill:"none",stroke:"#7c8cf8","stroke-width":"2","stroke-dasharray":"5 3",opacity:.8}));
// bg
const bg=el("rect",{class:"bg",width:w,height:h,rx:c.rx,ry:c.rx});
if(depth===0){bg.setAttribute("fill","url(#root-grad)");bg.setAttribute("filter","url(#glow)");}
else if(depth===1){bg.setAttribute("fill",color+"30");bg.setAttribute("stroke",color);bg.setAttribute("stroke-width","2");}
else if(depth===2){bg.setAttribute("fill",color+"18");bg.setAttribute("stroke",color+"bb");bg.setAttribute("stroke-width","1.5");}
else{bg.setAttribute("fill",color+"0e");bg.setAttribute("stroke",color+"77");bg.setAttribute("stroke-width","1");}
// label
const tc=depth<=1?"#fff":depth===2?"#e0e4f0":"#a8b0c8";
const txt=el("text",{x:w/2,y:h/2,"dominant-baseline":"central","text-anchor":"middle",
"font-size":c.fs,"font-weight":c.fw,fill:tc,style:"pointer-events:none;user-select:none;"});
txt.textContent=label;
grp.appendChild(bg); grp.appendChild(txt);
// collapse toggle
if(kids.length&&depth>0){
const bx=w-9,by=h-9;
const tg=el("g",{class:"tog","data-id":node._id});
const tc2=el("circle",{cx:bx,cy:by,r:8,fill:color+"33",stroke:color,"stroke-width":"1.2"});
const tt=el("text",{x:bx,y:by,"dominant-baseline":"central","text-anchor":"middle",
"font-size":"11","font-weight":"700",fill:color,style:"pointer-events:none;user-select:none;"});
tt.textContent=node._collapsed?"+":" −";
tg.appendChild(tc2); tg.appendChild(tt);
tg.addEventListener("mousedown",e=>e.stopPropagation());
tg.addEventListener("click",e=>{e.stopPropagation();toggle(node._id);});
grp.appendChild(tg);
}
// resize handles
grp.appendChild(el("rect",{class:"rh",x:w-HW,y:h*.15,width:HW*2,height:h*.7,rx:3,fill:color,"data-resize":"w","data-id":node._id}));
grp.appendChild(el("rect",{class:"rh-b",x:w*.15,y:h-HW,width:w*.7,height:HW*2,rx:3,fill:color,"data-resize":"h","data-id":node._id}));
grp.addEventListener("mousedown",e=>{
if(e.target.closest(".tog")||e.target.dataset.resize) return;
e.stopPropagation(); selectNode(node._id); startNodeDrag(e,node);
});
grp.addEventListener("contextmenu",e=>{
e.preventDefault(); e.stopPropagation(); selectNode(node._id); openCtxMenu(e.clientX,e.clientY,node._id);
});
g.appendChild(grp); nodeEls[node._id]=grp;
}
function patchNodeEl(node){
const grp=nodeEls[node._id]; if(!grp) return;
const p=pos[node._id]; if(!p) return;
const w=node._w,h=node._h;
grp.setAttribute("transform",`translate(p.x-w/2,p.y-h/2)`);
const bg=grp.querySelector(".bg");if(bg){bg.setAttribute("width",w);bg.setAttribute("height",h);}
const t=grp.querySelector("text[dominant-baseline]");if(t){t.setAttribute("x",w/2);t.setAttribute("y",h/2);}
const sr=grp.querySelector(".sel-ring");if(sr){sr.setAttribute("width",w+6);sr.setAttribute("height",h+6);}
const rh=grp.querySelector("[data-resize='w']");if(rh){rh.setAttribute("x",w-HW);rh.setAttribute("y",h*.15);rh.setAttribute("height",h*.7);}
const rb=grp.querySelector("[data-resize='h']");if(rb){rb.setAttribute("x",w*.15);rb.setAttribute("y",h-HW);rb.setAttribute("width",w*.7);}
const tg=grp.querySelector(".tog");
if(tg){const bc=tg.querySelector("circle"),bt=tg.querySelector("text");
if(bc){bc.setAttribute("cx",w-9);bc.setAttribute("cy",h-9);}
if(bt){bt.setAttribute("x",w-9);bt.setAttribute("y",h-9);}}
}
/* ══ SELECTION ══ */
function selectNode(id){
if(selectedId&&nodeEls[selectedId]) nodeEls[selectedId].classList.remove("selected");
selectedId=id;
if(id&&nodeEls[id]) nodeEls[id].classList.add("selected");
}
/* ══ CONTEXT MENU ══ */
function buildColorDots(){
const cont=document.getElementById("ctx-colors"); cont.innerHTML="";
const curNode=nodeMap[ctxTargetId];
const curColor=curNode?.color||null;
PALETTE.forEach(c=>{
const d=document.createElement("div");
d.className="color-dot"+(c===curColor?" active":"");
d.style.background=c; d.title=c;
d.onclick=()=>ctxAction("color",c); cont.appendChild(d);
});
const r=document.createElement("div");
r.className="color-dot"+(curColor===null?" active":"");
r.style.background="rgba(255,255,255,.15)";
r.style.cssText+=";font-size:11px;display:flex;align-items:center;justify-content:center;";
r.title="自动颜色";r.textContent="↺";r.onclick=()=>ctxAction("color",null);
cont.appendChild(r);
}
function openCtxMenu(x,y,id){
ctxTargetId=id; buildColorDots();
// Update color label to show what will be changed
const lbl=document.getElementById("ctx-color-label");
if(lbl){
const n=nodeMap[id];
const depth=n?n._depth:0;
if(depth===0) lbl.textContent="更改根节点颜色";
else if(depth===1) lbl.textContent="更改分支颜色(影响子节点默认色)";
else lbl.textContent="更改此节点颜色";
}
const menu=document.getElementById("ctx-menu"); menu.classList.add("open");
menu.style.left=x+"px"; menu.style.top=y+"px";
requestAnimationFrame(()=>{
const r=menu.getBoundingClientRect();
if(r.right>window.innerWidth) menu.style.left=(x-r.width)+"px";
if(r.bottom>window.innerHeight) menu.style.top=(y-r.height)+"px";
});
}
function closeCtxMenu(){document.getElementById("ctx-menu").classList.remove("open");ctxTargetId=null;}
function ctxAction(action,extra){
// Save target id BEFORE closeCtxMenu() nulls ctxTargetId
const targetId=ctxTargetId;
closeCtxMenu();
const node=nodeMap[targetId]; if(!node&&action!=="delete") return;
if(action==="add-child"){
snapshotForUndo();
const d=Math.min(node._depth+1,CFG.length-1);
const nn={_id:newId(),label:"新节点",children:[],_depth:d,_collapsed:false,_pinned:false,_px:null,_py:null};
nn._w=measureW("新节点",d); nn._h=CFG[d].h; nodeMap[nn._id]=nn;
// Root node uses .branches, all others use .children
if(node._depth===0){
if(!node.branches) node.branches=[];
node.branches.push(nn);
} else {
if(!node.children) node.children=[];
node.children.push(nn);
}
rebuild();
setTimeout(()=>selectNode(nn._id), 30);
return;
}
if(action==="add-sibling"){
snapshotForUndo();
const p=pos[targetId]; if(!p||!p.parentId){showToast("根节点无法添加兄弟节点");return;}
const parent=nodeMap[p.parentId]||TREE;
const arr=parent._depth===0?(parent.branches||[]):(parent.children||[]);
const idx=arr.findIndex(c=>c._id===targetId);
const d=node._depth;
const nn={_id:newId(),label:"新节点",children:[],_depth:d,_collapsed:false,_pinned:false,_px:null,_py:null};
nn._w=measureW("新节点",d); nn._h=CFG[Math.min(d,CFG.length-1)].h; nodeMap[nn._id]=nn;
arr.splice(idx+1,0,nn); rebuild();
setTimeout(()=>selectNode(nn._id), 30);
return;
}
if(action==="delete"){
if(!node){return;} if(node._depth===0){showToast("不能删除根节点");return;}
snapshotForUndo();
const p=pos[targetId]; if(!p||!p.parentId) return;
const parent=nodeMap[p.parentId]||TREE;
const arr=parent._depth===0?(parent.branches||[]):(parent.children||[]);
const idx=arr.findIndex(c=>c._id===targetId);
if(idx>=0) arr.splice(idx,1);
function rm(n){delete nodeMap[n._id];(n.children||[]).forEach(rm);}
rm(node);
if(selectedId===targetId) selectNode(null);
rebuild(); return;
}
if(action==="color"){
snapshotForUndo();
// Set color on the exact node clicked — no walk-up to branch root
if(extra===null) delete node.color; else node.color=extra;
rebuild(); return;
}
}
// Use mousedown (not click) to close menu so it doesn't race with menu item onclick
document.addEventListener("mousedown",e=>{
const menu=document.getElementById("ctx-menu");
if(menu.classList.contains("open")&&!menu.contains(e.target)) closeCtxMenu();
});
document.addEventListener("contextmenu",e=>{
if(["wrap","svg","edges-g","nodes-g"].includes(e.target.id)||(e.target.tagName==="svg")||(e.target.parentElement&&e.target.parentElement.id==="edges-g"))
e.preventDefault();
});
/* ══ UNDO / REDO ══════════════════════════════════════════════════════════
操作前调用 snapshotForUndo(),将当前树结构序列化压入撤销栈。
Ctrl+Z 弹出并恢复,Ctrl+Y/Ctrl+Shift+Z 重做。
════════════════════════════════════════════════════════════════════════ */
function _treeSnapshot(){
// 序列化当前树(含颜色、折叠状态、位置)
function snap(node){
const out={label:node.label,central:node.central,color:node.color,
_collapsed:node._collapsed,_pinned:node._pinned,_px:node._px,_py:node._py,
_w:node._w,_h:node._h};
if((node.children||[]).length) out.children=(node.children||[]).map(snap);
if((node.branches||[]).length) out.branches=(node.branches||[]).map(snap);
return out;
}
return JSON.stringify(snap(TREE));
}
function _restoreSnapshot(json){
const saved=JSON.parse(json);
function restore(live,saved){
live.label=saved.label; live.central=saved.central;
if(saved.color) live.color=saved.color; else delete live.color;
live._collapsed=saved._collapsed||false;
live._pinned=saved._pinned||false;
live._px=saved._px??null; live._py=saved._py??null;
live._w=saved._w||null; live._h=saved._h||null;
// Rebuild children array from saved data
if(saved.children){
live.children=(saved.children).map(sc=>{
const n={label:sc.label||"",children:[],branches:[]};
restore(n,sc); return n;
});
} else { live.children=[]; }
if(saved.branches){
live.branches=(saved.branches).map(sb=>{
const n={label:sb.label||"",children:[],branches:[]};
restore(n,sb); return n;
});
}
}
restore(TREE,saved);
// 清理所有可能持有旧节点引用的状态,防止 stale reference crash
activeOp = null;
wrap.style.cursor = "";
selectNode(null);
ctxTargetId = null;
_pushScheduled = false;
// 关键修复:_nid 重置为 0 后,必须清除 TREE._id,否则 TREE 保留旧 _id(如 "n1"),
// 而 annotate 从 n1 开始分配,导致第一个分支也拿到 "n1",产生 ID 碰撞,
// nodeMap["n1"] 被分支覆盖,TREE 从 nodeMap 消失,渲染完全混乱。
delete TREE._id;
_nid=0; nodeMap={};
annotate(TREE,0,null);
rebuild();
}
function snapshotForUndo(){
_undoStack.push(_treeSnapshot());
if(_undoStack.length>MAX_UNDO) _undoStack.shift();
_redoStack=[]; // new action clears redo
}
function undo(){
if(!_undoStack.length){ showToast("没有可撤销的操作",1600); return; }
_redoStack.push(_treeSnapshot());
_restoreSnapshot(_undoStack.pop());
showToast("↩ 已撤销",1400);
}
function redo(){
if(!_redoStack.length){ showToast("没有可重做的操作",1600); return; }
_undoStack.push(_treeSnapshot());
_restoreSnapshot(_redoStack.pop());
showToast("↪ 已重做",1400);
}
/* ══ KEYBOARD ══ */
document.addEventListener("keydown",e=>{
// Undo / Redo
if((e.ctrlKey||e.metaKey)&&!e.shiftKey&&e.key==="z"){e.preventDefault();undo();return;}
if((e.ctrlKey||e.metaKey)&&(e.key==="y"||(e.shiftKey&&e.key==="z"))){e.preventDefault();redo();return;}
if((e.key==="Delete"||e.key==="Backspace")&&selectedId){
e.preventDefault();
ctxTargetId=selectedId; ctxAction("delete"); return;
}
if(e.key==="Tab"&&selectedId){
e.preventDefault();
ctxTargetId=selectedId; ctxAction("add-child"); return;
}
if(e.key==="Escape"){selectNode(null);closeCtxMenu();}
});
/* ══ DRAG REPULSION ══════════════════════════════════════════════════════
链式传播算法:
1. 以被拖动节点为压力源,计算每个节点到压力源的距离
2. 按距离从近到远排序,依次推开——近的节点先让位,压力向外传播
3. 推开时近压力源的节点固定(已被推过),只推远端节点
4. 多轮迭代直到全局无重叠,避免振荡
════════════════════════════════════════════════════════════════════════ */
const DRAG_PAD = 10;
const MAX_ITER = 15;
function pushAway(draggedId){
const allIds = Object.keys(pos);
if(allIds.length < 2) return;
// 预计算半尺寸
const hw = {}, hh = {};
allIds.forEach(id => {
const n = nodeMap[id] || TREE;
hw[id] = n._w/2 + DRAG_PAD/2;
hh[id] = n._h/2 + DRAG_PAD/2;
});
const dp = pos[draggedId];
for(let iter = 0; iter < MAX_ITER; iter++){
let anyOverlap = false;
// 按到拖动节点的距离从近到远排序,让压力从内向外传播
const sorted = allIds.slice().sort((a, b) => {
const pa = pos[a], pb = pos[b];
const da = (pa.x-dp.x)**2 + (pa.y-dp.y)**2;
const db = (pb.x-dp.x)**2 + (pb.y-dp.y)**2;
return da - db;
});
for(let i = 0; i < sorted.length; i++){
for(let j = i+1; j < sorted.length; j++){
const ai = sorted[i], aj = sorted[j]; // ai 比 aj 更靠近拖动节点
const pi = pos[ai], pj = pos[aj];
if(!pi || !pj) continue;
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const overlapX = (hw[ai] + hw[aj]) - Math.abs(dx);
const overlapY = (hh[ai] + hh[aj]) - Math.abs(dy);
if(overlapX <= 0 || overlapY <= 0) continue;
anyOverlap = true;
// 关键:ai 更靠近压力源(已被处理过),固定 ai 只推 aj
// 压力单向向外传播,不会产生振荡
if(overlapX <= overlapY){
pj.x += overlapX * (dx >= 0 ? 1 : -1);
} else {
pj.y += overlapY * (dy >= 0 ? 1 : -1);
}
}
}
if(!anyOverlap) break;
}
// 同步视觉、side 和 pinned 坐标
allIds.forEach(id => {
const p = pos[id]; if(!p) return;
const n = nodeMap[id] || TREE;
// 更新 side:始终以父节点为参照
const parentP = p.parentId ? pos[p.parentId] : null;
if(parentP) p.side = p.x >= parentP.x ? 1 : -1;
if(n._pinned){ n._px = p.x; n._py = p.y; }
patchNodeEl(n);
refreshEdgesFor(id);
});
}
/* ══ GLOBAL SEPARATION ═══════════════════════════════════════════════════
布局完成后对所有节点做一次全局分离,确保不重叠。
与 pushAway 的区别:没有固定压力源,每对重叠节点各自向外移动一半,
适合初始布局、切换布局、添加/删除节点后的全局整理。
════════════════════════════════════════════════════════════════════════ */
function separateAll(){
const allIds = Object.keys(pos);
if(allIds.length < 2) return;
const hw = {}, hh = {};
allIds.forEach(id => {
const n = nodeMap[id] || TREE;
hw[id] = n._w/2 + DRAG_PAD/2;
hh[id] = n._h/2 + DRAG_PAD/2;
});
// 预处理:给完全重合的节点施加微小扰动,防止对称死锁
for(let i = 0; i < allIds.length; i++){
for(let j = i+1; j < allIds.length; j++){
const pi = pos[allIds[i]], pj = pos[allIds[j]];
if(!pi||!pj) continue;
if(Math.abs(pi.x-pj.x)<0.1 && Math.abs(pi.y-pj.y)<0.1){
// 按索引差给一个确定性的角度扰动,避免随机性
const angle = (j - i) * 2.399; // 黄金角,均匀分布
pj.x += Math.cos(angle) * 0.5;
pj.y += Math.sin(angle) * 0.5;
}
}
}
const SEP_ITER = allIds.length * 4; // 实测:n*4 覆盖 99% 的实际场景,50节点以内 <10ms
for(let iter = 0; iter < SEP_ITER; iter++){
let anyOverlap = false;
for(let i = 0; i < allIds.length; i++){
for(let j = i+1; j < allIds.length; j++){
const ai = allIds[i], aj = allIds[j];
const pi = pos[ai], pj = pos[aj];
if(!pi || !pj) continue;
const dx = pj.x - pi.x;
const dy = pj.y - pi.y;
const overlapX = (hw[ai] + hw[aj]) - Math.abs(dx);
const overlapY = (hh[ai] + hh[aj]) - Math.abs(dy);
if(overlapX <= 0 || overlapY <= 0) continue;
anyOverlap = true;
const fixI = (nodeMap[ai]||TREE)._depth === 0;
const fixJ = (nodeMap[aj]||TREE)._depth === 0;
if(overlapX <= overlapY){
const push = overlapX * (dx >= 0 ? 1 : -1);
if(!fixI && !fixJ){ pi.x -= push*0.5; pj.x += push*0.5; }
else if(fixI) pj.x += push;
else pi.x -= push;
} else {
const push = overlapY * (dy >= 0 ? 1 : -1);
if(!fixI && !fixJ){ pi.y -= push*0.5; pj.y += push*0.5; }
else if(fixI) pj.y += push;
else pi.y -= push;
}
}
}
if(!anyOverlap) break;
}
// 同步 side / pinned 坐标
allIds.forEach(id => {
const p = pos[id]; if(!p) return;
const n = nodeMap[id] || TREE;
const parentP = p.parentId ? pos[p.parentId] : null;
if(parentP) p.side = p.x >= parentP.x ? 1 : -1;
if(n._pinned){ n._px = p.x; n._py = p.y; }
});
}
/* ══ INTERACTION ══ */
let activeOp=null, T={x:0,y:0,s:1};
function applyTransform(){
const t=`translate(T.x,T.y) scale(T.s)`;
document.getElementById("edges-g").setAttribute("transform",t);
document.getElementById("nodes-g").setAttribute("transform",t);
}
function svgXY(cx,cy){return{x:(cx-T.x)/T.s,y:(cy-T.y)/T.s};}
function startNodeDrag(e,node){const sv=svgXY(e.clientX,e.clientY);activeOp={type:"nodedrag",node,ox:sv.x-pos[node._id].x,oy:sv.y-pos[node._id].y,moved:false};}
function startResizeW(e,node){e.stopPropagation();const sv=svgXY(e.clientX,e.clientY);activeOp={type:"rw",node,sx:sv.x,sw:node._w};}
function startResizeH(e,node){e.stopPropagation();const sv=svgXY(e.clientX,e.clientY);activeOp={type:"rh",node,sy:sv.y,sh:node._h};}
window.addEventListener("mousemove",e=>{
if(!activeOp) return;
if(activeOp.type==="canvas"){T.x=e.clientX-activeOp.sx;T.y=e.clientY-activeOp.sy;applyTransform();return;}
if(activeOp.type==="nodedrag"){
const sv=svgXY(e.clientX,e.clientY);
if(!activeOp.moved&&Math.hypot(sv.x-pos[activeOp.node._id].x-activeOp.ox,sv.y-pos[activeOp.node._id].y-activeOp.oy)<2) return;
activeOp.moved=true;
const node=activeOp.node;
node._pinned=true; node._px=sv.x-activeOp.ox; node._py=sv.y-activeOp.oy;
pos[node._id].x=node._px; pos[node._id].y=node._py;
// 实时更新 side:节点在父节点哪侧由实际坐标决定
const _pp=pos[pos[node._id].parentId]; if(_pp) pos[node._id].side=pos[node._id].x>=_pp.x?1:-1;
patchNodeEl(node); refreshEdgesFor(node._id);
// rAF throttle: 每帧最多执行一次 pushAway,避免高频 mousemove 掉帧
if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(node._id);});}
return;
}
if(activeOp.type==="rw"){const sv=svgXY(e.clientX,e.clientY);activeOp.node._w=Math.max(MIN_W,activeOp.sw+(sv.x-activeOp.sx));patchNodeEl(activeOp.node);refreshEdgesFor(activeOp.node._id);if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(activeOp.node._id);});}return;}
if(activeOp.type==="rh"){const sv=svgXY(e.clientX,e.clientY);activeOp.node._h=Math.max(MIN_H,activeOp.sh+(sv.y-activeOp.sy));patchNodeEl(activeOp.node);refreshEdgesFor(activeOp.node._id);if(!_pushScheduled){_pushScheduled=true;requestAnimationFrame(()=>{_pushScheduled=false;pushAway(activeOp.node._id);});}return;}
});
window.addEventListener("mouseup",()=>{
if(!activeOp) return;
if(activeOp.type==="nodedrag"){
if(!activeOp.moved){
const node=activeOp.node,kids=node.children||node.branches||[];
if(kids.length&&node._depth>0) toggle(node._id);
} else {
snapshotForUndo(); // 拖动结束后保存快照
}
}
if(activeOp.type==="rw"||activeOp.type==="rh") snapshotForUndo();
activeOp=null; wrap.style.cursor="";
});
const wrap=document.getElementById("wrap");
wrap.addEventListener("mousedown",e=>{
if(activeOp) return;
if(e.target===e.currentTarget||e.target.tagName==="svg"||["edges-g","nodes-g"].includes(e.target.id))
{selectNode(null);closeCtxMenu();}
activeOp={type:"canvas",sx:e.clientX-T.x,sy:e.clientY-T.y}; wrap.style.cursor="grabbing";
});
document.getElementById("nodes-g").addEventListener("mousedown",e=>{
const rt=e.target.dataset.resize,nid=e.target.dataset.id; if(!rt||!nid) return;
e.stopPropagation(); const node=nodeMap[nid]; if(!node) return;
if(rt==="w")startResizeW(e,node); if(rt==="h")startResizeH(e,node);
});
wrap.addEventListener("wheel",e=>{
e.preventDefault();
const r=wrap.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
const f=e.deltaY<0?1.11:0.9, ns=Math.min(Math.max(T.s*f,0.1),6);
T.x=mx-(ns/T.s)*(mx-T.x); T.y=my-(ns/T.s)*(my-T.y); T.s=ns; applyTransform();
},{passive:false});
let tDrag=null;
wrap.addEventListener("touchstart",e=>{if(e.touches.length===1)tDrag={sx:e.touches[0].clientX-T.x,sy:e.touches[0].clientY-T.y};},{passive:true});
wrap.addEventListener("touchmove",e=>{if(tDrag&&e.touches.length===1){T.x=e.touches[0].clientX-tDrag.sx;T.y=e.touches[0].clientY-tDrag.sy;applyTransform();}},{passive:true});
wrap.addEventListener("touchend",()=>tDrag=null);
/* ══ COLLAPSE ══ */
function toggle(id){const n=nodeMap[id];if(!n)return;n._collapsed=!n._collapsed;rebuild();}
function expandAll(){Object.values(nodeMap).forEach(n=>n._collapsed=false);rebuild();}
function collapseAll(){Object.values(nodeMap).forEach(n=>{if(n._depth>=1)n._collapsed=true;});rebuild();}
function rebuild(){
layout(TREE);
separateAll(); // 布局后全局分离,确保不重叠
renderAll(TREE);
}
function resetView(){T={x:wrap.clientWidth/2,y:wrap.clientHeight/2,s:1};applyTransform();}
function zoomIn(){T.s=Math.min(T.s*1.2,6);applyTransform();}
function zoomOut(){T.s=Math.max(T.s/1.2,0.1);applyTransform();}
/* ══ TOAST ══ */
function showToast(msg,dur=2400){const t=document.getElementById("toast");t.textContent=msg;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),dur);}
/* ══ EXPORT ══ */
function getBounds(){
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
[...Object.values(nodeMap),TREE].forEach(n=>{
const p=pos[n._id];if(!p)return;
x0=Math.min(x0,p.x-n._w/2);x1=Math.max(x1,p.x+n._w/2);
y0=Math.min(y0,p.y-n._h/2);y1=Math.max(y1,p.y+n._h/2);
});
const pad=60; return{x0:x0-pad,y0:y0-pad,w:x1-x0+pad*2,h:y1-y0+pad*2};
}
function buildExportSVG(){
const b=getBounds();
const clone=document.getElementById("svg").cloneNode(true);
clone.querySelectorAll(".rh,.rh-b,.tog,.sel-ring").forEach(e=>e.remove());
clone.querySelectorAll(".nd").forEach(g=>g.classList.remove("selected"));
clone.querySelectorAll("#edges-g,#nodes-g").forEach(g=>g.removeAttribute("transform"));
clone.setAttribute("viewBox",`b.x0 b.y0 b.w b.h`);
clone.setAttribute("width",Math.round(b.w)); clone.setAttribute("height",Math.round(b.h));
clone.style.cssText="";
const bg=document.createElementNS(SVG_NS,"rect");
bg.setAttribute("x",b.x0);bg.setAttribute("y",b.y0);bg.setAttribute("width",b.w);bg.setAttribute("height",b.h);bg.setAttribute("fill","#0d0f1a");
clone.insertBefore(bg,clone.firstChild);
const st=document.createElementNS(SVG_NS,"style");
st.textContent='text{font-family:-apple-system,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;}';
clone.insertBefore(st,clone.firstChild);
return{svgEl:clone,b};
}
function dlBlob(blob,name){
const url=URL.createObjectURL(blob),a=document.createElement("a");
a.href=url;a.download=name;document.body.appendChild(a);a.click();
setTimeout(()=>{document.body.removeChild(a);URL.revokeObjectURL(url);},300);
}
function exportAs(fmt){
showToast("正在导出 "+fmt.toUpperCase()+" …");
const{svgEl,b}=buildExportSVG();
const svgStr=new XMLSerializer().serializeToString(svgEl);
const safe=TITLE.replace(/[\\/:*?"<>|]/g,"_");
if(fmt==="svg"){dlBlob(new Blob([svgStr],{type:"image/svg+xml"}),safe+".svg");return;}
const sc=fmt==="jpg"?2:2.5;
const canvas=document.createElement("canvas"); canvas.width=b.w*sc; canvas.height=b.h*sc;
const ctx=canvas.getContext("2d");
if(fmt==="jpg"){ctx.fillStyle="#0d0f1a";ctx.fillRect(0,0,canvas.width,canvas.height);}
const img=new Image();
const bUrl=URL.createObjectURL(new Blob([svgStr],{type:"image/svg+xml"}));
img.onload=()=>{
ctx.drawImage(img,0,0,canvas.width,canvas.height); URL.revokeObjectURL(bUrl);
if(fmt==="png") canvas.toBlob(bl=>dlBlob(bl,safe+".png"),"image/png");
else if(fmt==="jpg") canvas.toBlob(bl=>dlBlob(bl,safe+".jpg"),"image/jpeg",0.93);
else if(fmt==="pdf") makePDF(canvas,b,safe);
};
img.src=bUrl;
}
function makePDF(canvas,b,safe){
const jData=canvas.toDataURL("image/jpeg",0.92).split(",")[1];
const jBytes=Uint8Array.from(atob(jData),c=>c.charCodeAt(0));
const W=Math.round(b.w),H=Math.round(b.h);
const enc=new TextEncoder();
function str(s){return enc.encode(s);}
const stream=`q W 0 0 H 0 0 cm /Im1 Do Q`;
const objs=[str("%PDF-1.4\n"),str("1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n"),
str(`2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n`),
str(`3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 W H]/Contents 4 0 R/Resources<</XObject<</Im1 5 0 R>>>>>>\nendobj\n`),
str(`4 0 obj\n<</Length stream.length>>\nstream\nstream\nendstream\nendobj\n`),
str(`5 0 obj\n<</Type/XObject/Subtype/Image/Width canvas.width/Height canvas.height/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/DCTDecode/Length jBytes.length>>\nstream\n`),
jBytes,str("\nendstream\nendobj\n"),
str("xref\n0 6\n0000000000 65535 f \ntrailer\n<</Size 6/Root 1 0 R>>\nstartxref\n0\n%%EOF\n")];
const total=objs.reduce((s,o)=>s+o.length,0); const buf=new Uint8Array(total); let off=0;
for(const o of objs){buf.set(o,off);off+=o.length;}
dlBlob(new Blob([buf],{type:"application/pdf"}),safe+".pdf");
}
/* ══ XMIND ══ */
function exportXmind(){
showToast("正在生成 XMind …");
// ── helpers ────────────────────────────────────────────────────────────
function uid(){ return crypto.randomUUID().replace(/-/g,"").slice(0,26); }
function xe(s){ return String(s).replace(/&/g,"&").replace(/</g,"<")
.replace(/>/g,">").replace(/"/g,"""); }
// ── content.json (XMind 2020+) ────────────────────────────────────────
function xnJson(node){
const kids=(node.branches||[]).concat(node.children||[]);
const o={id:uid(),class:"topic",title:node.label||node.central||""};
if(kids.length) o.children={attached:kids.map(xnJson)};
if(node.color) o.style={id:uid(),properties:{
"line-color":node.color,"background-color":node.color+"33",
"border-line-color":node.color,"line-width":"2pt",
"shape-class":"org.xmind.topicShape.roundedRect"}};
return o;
}
const rootJson=xnJson(TREE);
rootJson.structureClass="org.xmind.ui.map.unbalanced";
const contentJson=[{id:uid(),class:"sheet",title:TITLE,rootTopic:rootJson,theme:{},extensions:[]}];
// ── content.xml (XMind 8) ─────────────────────────────────────────────
function xnXml(node, depth){
const kids=(node.branches||[]).concat(node.children||[]);
const label=node.label||node.central||"";
const ind=" ".repeat(depth);
let s=`ind<topic id="uid()"`;
if(depth===0) s+=' structure-class="org.xmind.ui.map.unbalanced"';
s+=`>\nind <title>xe(label)</title>`;
if(kids.length){
s+=`\nind <children>\nind <topics type="attached">`;
for(const c of kids) s+="\n"+xnXml(c,depth+3);
s+=`\nind </topics>\nind </children>`;
}
s+=`\nind</topic>`;
return s;
}
const sheetId=uid();
const xmlRoot=xnXml(TREE,0);
const contentXml=`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n`+
`<xmap-content xmlns="urn:xmind:xmap:xmlns:content:2.0"\n`+
` xmlns:fo="http://www.w3.org/1999/XSL/Format"\n`+
` xmlns:svg="http://www.w3.org/2000/svg"\n`+
` xmlns:xhtml="http://www.w3.org/1999/xhtml"\n`+
` xmlns:xlink="http://www.w3.org/1999/xlink"\n`+
` version="2.0">\n`+
` <sheet id="sheetId">\n`+
xmlRoot+"\n"+
` <title>xe(TITLE)</title>\n`+
` </sheet>\n`+
`</xmap-content>`;
const stylesXml=`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n`+
`<xmap-styles xmlns="urn:xmind:xmap:xmlns:style:2.0" version="2.0"></xmap-styles>`;
// ── metadata & manifest ────────────────────────────────────────────────
const meta={modifier:"",created:new Date().toISOString().slice(0,19)+".000+0000",
creator:{name:"OpenClaw MindMap",version:"5.0",platform:""}};
const mf={"file-entries":{
"content.json":{"media-type":"application/json"},
"content.xml": {"media-type":"text/xml"},
"styles.xml": {"media-type":"text/xml"},
"metadata.json":{"media-type":"application/json"},
"manifest.json":{"media-type":"application/json"}}};
// ── ZIP builder ────────────────────────────────────────────────────────
function u16(v){const b=new Uint8Array(2);new DataView(b.buffer).setUint16(0,v,true);return b;}
function u32(v){const b=new Uint8Array(4);new DataView(b.buffer).setUint32(0,v,true);return b;}
function crc32(d){
if(!crc32.t){crc32.t=new Uint32Array(256);for(let i=0;i<256;i++){let c=i;for(let j=0;j<8;j++)c=c&1?0xEDB88320^(c>>>1):c>>>1;crc32.t[i]=c;}}
let c=0xFFFFFFFF;for(const b of d)c=crc32.t[(c^b)&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
}
function cat(...a){const t=a.reduce((s,x)=>s+x.length,0),o=new Uint8Array(t);let p=0;for(const x of a){o.set(x,p);p+=x.length;}return o;}
const enc=new TextEncoder();
const files=[
["manifest.json", enc.encode(JSON.stringify(mf,null,2))],
["content.json", enc.encode(JSON.stringify(contentJson,null,2))],
["content.xml", enc.encode(contentXml)],
["styles.xml", enc.encode(stylesXml)],
["metadata.json", enc.encode(JSON.stringify(meta,null,2))],
];
const lParts=[],cds=[];let dataOff=0;
for(const[name,data]of files){
const nb=enc.encode(name),crc=crc32(data),sz=data.length;
const lh=cat(new Uint8Array([0x50,0x4B,0x03,0x04]),u16(20),u16(0),u16(0),u16(0),u16(0),
u32(crc),u32(sz),u32(sz),u16(nb.length),u16(0),nb);
const cd=cat(new Uint8Array([0x50,0x4B,0x01,0x02]),u16(20),u16(20),u16(0),u16(0),u16(0),u16(0),
u32(crc),u32(sz),u32(sz),u16(nb.length),u16(0),u16(0),u16(0),u16(0),u32(0),u32(dataOff),nb);
lParts.push(lh,data);cds.push(cd);dataOff+=lh.length+sz;
}
const cdBytes=cat(...cds);
const eocd=cat(new Uint8Array([0x50,0x4B,0x05,0x06]),u16(0),u16(0),
u16(files.length),u16(files.length),u32(cdBytes.length),u32(dataOff),u16(0));
dlBlob(new Blob([cat(...lParts,cdBytes,eocd)],{type:"application/octet-stream"}),
TITLE.replace(/[\\/:*?"<>|]/g,"_")+".xmind");
}
/* ══ BOOT ══ */
RAW._depth=0; RAW.label=RAW.central;
annotate(RAW,0);
const TREE=RAW;
layout(TREE);
separateAll(); // 初始布局后分离
renderAll(TREE);
resetView();
</script>
</body>
</html>
FILE:examples/python_learning.json
{
"central": "Python 学习路径",
"branches": [
{
"label": "基础语法",
"color": "#4A90D9",
"children": [
{"label": "数据类型", "children": ["int/float", "str", "list/tuple", "dict/set"]},
{"label": "控制流", "children": ["if/elif/else", "for/while", "break/continue"]},
{"label": "函数", "children": ["定义与调用", "参数类型", "lambda", "装饰器"]},
"模块与包"
]
},
{
"label": "面向对象",
"color": "#27AE60",
"children": [
{"label": "类与对象", "children": ["属性", "方法", "__init__"]},
{"label": "继承", "children": ["单继承", "多继承", "super()"]},
"封装与多态",
"魔术方法"
]
},
{
"label": "常用库",
"color": "#E86C3A",
"children": [
{"label": "数据处理", "children": ["NumPy", "Pandas", "Polars"]},
{"label": "可视化", "children": ["Matplotlib", "Seaborn", "Plotly"]},
{"label": "Web 框架", "children": ["FastAPI", "Django", "Flask"]},
{"label": "AI/ML", "children": ["PyTorch", "scikit-learn", "transformers"]}
]
},
{
"label": "工程实践",
"color": "#9B59B6",
"children": [
{"label": "项目管理", "children": ["虚拟环境", "依赖管理", "pyproject.toml"]},
{"label": "代码质量", "children": ["类型注解", "linting", "格式化"]},
{"label": "测试", "children": ["pytest", "unittest", "mock"]},
"CI/CD"
]
},
{
"label": "进阶专题",
"color": "#00BCD4",
"children": [
{"label": "并发编程", "children": ["多线程", "多进程", "asyncio"]},
{"label": "性能优化", "children": ["Cython", "numba", "profiling"]},
"元编程",
"设计模式"
]
}
]
}