{“content”:”---\nname: obsidian-canvas-auto-layout\ndescription: Auto-layout Obsidian Canvas (.canvas) files using a radial tree algorithm — positions root at center, children spread angularly by subtree size. No numpy/networkx needed.\n---\n\n# Obsidian Canvas Auto-Layout\n\nReposition all nodes in a .canvas file so the tree is radially organized: root at center, branches radiating outward without overlaps.\n\n## Problem\n- Obsidian Canvas nodes drift randomly over time\n- Arrows cross each other chaotically\n- Root node (“幸福”) not at center\n\n## Solution: Custom Radial Layout (pure Python, no numpy)\n\n### Step 1: Download Canvas from PC\nbash\nscp -i ~/.ssh/id_ed25519_hermes \\\n 'Sozo@100.83.112.84:H:/My Drive/Jakephone/Obsidian Vault/幸福-canvas.canvas' \\\n ~/canvas_work.canvas\n\n\n### Step 2: Run Layout Script\npython\nimport json, math\n\ncanvas_path = '/data/data/com.termux/files/home/canvas_work.canvas'\noutput_path = '/data/data/com.termux/files/home/canvas_relayout.canvas'\n\nwith open(canvas_path, 'rb') as f:\n raw = f.read()\ntext = raw.decode('gbk', errors='ignore')\n\n# Parse JSON (canvas may have trailing junk)\nfirst_brace = text.find('{')\ndepth = 0\njson_end = 0\nfor i, c in enumerate(text[first_brace:], first_brace):\n if c == '{': depth += 1\n elif c == '}':\n depth -= 1\n if depth == 0:\n json_end = i + 1\n break\ndata = json.loads(text[first_brace:json_end])\n\nnodes = data.get('nodes', [])\nedges = data.get('edges', [])\n\n# Build adjacency\nchildren = {n['id']: [] for n in nodes}\nfor e in edges:\n children[e['fromNode']].append(e['toNode'])\n\n# BFS to get depth of each node\ndepth_of = {}\nqueue = [('n1', 0)] # n1 is the root\nwhile queue:\n node, d = queue.pop(0)\n depth_of[node] = d\n for child in children[node]:\n queue.append((child, d + 1))\n\n# Subtree sizes\nsubtree_size = {}\ndef get_subtree_size(node):\n size = 1\n for child in children[node]:\n size += get_subtree_size(child)\n subtree_size[node] = size\n return size\nget_subtree_size('n1')\n\n# Radial layout parameters\nCENTER_X, CENTER_Y = 960, 3000 # center of canvas viewport\nRADIUS_PER_DEPTH = 280 # pixels between depth levels\n\npositions = {}\n\ndef assign_position(node, start_angle, end_angle, depth):\n \"\"\"Spread children angularly proportional to subtree size.\"\"\"\n total = subtree_size[node]\n angle = (start_angle + end_angle) / 2\n r = depth * RADIUS_PER_DEPTH\n positions[node] = (CENTER_X + r * math.cos(angle), CENTER_Y + r * math.sin(angle))\n \n if total == 1 or not children[node]:\n return\n \n span = end_angle - start_angle\n if span <= 0:\n span += 2 * math.pi\n total_child_subs = sum(subtree_size[c] for c in children[node])\n \n current_angle = start_angle\n for child in children[node]:\n child_span = span * subtree_size[child] / total_child_subs\n assign_position(child, current_angle, current_angle + child_span, depth + 1)\n current_angle += child_span\n\nassign_position('n1', 0, 2 * math.pi, 0)\n\n# Update node positions\nfor node in nodes:\n nid = node['id']\n if nid in positions:\n node['x'] = int(positions[nid][0])\n node['y'] = int(positions[nid][1])\n\n# Save\nwith open(output_path, 'w', encoding='utf-8') as f:\n json.dump(data, f, ensure_ascii=False)\nprint(\"Done\")\n\n\n### Step 3: Upload Back (CRITICAL: UTF-8 BOM Issue)\n\nPowerShell 的 WriteAllText 預設寫入 UTF-8 BOM,會導致 Obsidian JSON 解析失敗(Unexpected UTF-8 BOM)。必須用 UTF8Encoding $false 去除 BOM:\n\nbash\n# 1. Base64 編碼\npython3 -c \"import base64; open('canvas_b64.txt','w').write(base64.b64encode(open('canvas_relayout.canvas','rb').read()).decode())\"\n\n# 2. SCP 上傳 Base64 + PowerShell 腳本到 PC\nscp -i ~/.ssh/id_ed25519_hermes canvas_b64.txt \\\n 'Sozo@100.83.112.84:C:\\Users\\sozo\\AppData\\Local\\Temp\\canvas_b64.txt'\n\n# 3. PowerShell 寫入(無 BOM)\nssh -i ~/.ssh/id_ed25519_hermes Sozo@100.83.112.84 \\\n 'powershell -File C:\\Users\\sozo\\AppData\\Local\\Temp\\restore_canvas.ps1'\n\n\nrestore_canvas.ps1 內容:\npowershell\n$b64 = Get-Content 'C:\\Users\\sozo\\AppData\\Local\\Temp\\canvas_b64.txt' -Raw\n$bytes = [System.Convert]::FromBase64String($b64)\n$json = [System.Text.Encoding]::UTF8.GetString($bytes)\n$utf8NoBom = New-Object System.Text.UTF8Encoding $false\n$path = 'H:\\My Drive\\Jakephone\\Obsidian Vault\\幸福-canvas.canvas'\n[System.IO.File]::WriteAllText($path, $json, $utf8NoBom)\nWrite-Output \"Written: $((Get-Item $path).Length) bytes\"\n\n\n驗證 BOM 已去除(首字節應為 123 = {,不是 239):\npowershell\n[System.IO.File]::ReadAllBytes($path)[0..4] # 正常: 123 10 9 34 110\n\n\nObsidian Canvas JSON 關鍵格式:\n- 邊欄位:fromNode, toNode(⚠️ 不是 from/to)\n- 邊 ID 必須是數字(如 e1, e2)\n- 節點必要欄位:id, type, x, y, width, height\n\nLocal REST API 對 Canvas 無效: POST /vault/xxx.canvas 返回 HTTP 204 但不會寫入,必須用 SCP + PowerShell 方法。\n\n### Step 4: Verify\nOpen Obsidian, close and reopen the Vault. The canvas should show:\n- Root node (n1) at center\n- Tree branching outward radially\n- No crossed edges\n\n## Key Discoveries\n- GBK encoding: Canvas files are GBK, not UTF-8. Read with decode('gbk', errors='ignore')\n- JSON parsing: File may contain trailing data after valid JSON. Use depth tracking to find true end.\n- networkx shell_layout requires numpy: numpy pip install times out on Termux. Custom algorithm avoids this.\n- Root node is n1: The node with ID n1 and no incoming edges is the root.\n\n## Important Fixes Applied (2026-04-24)\n- 幸福-canvas编码确认:GBK(不是UTF-8)\n- 幸福频道+健康频道已从幸福-canvas删除(47节点)\n- HS Design节点在树中丢失,需对照原始文职规划重建\n- 用户原始规划文本:~/.hermes/cache/documents/doc_4d92abbc12f7_d9e2fdff863c6fb5.txt\n\n## Upload to H: Drive (Google Drive mount) — CRITICAL\n❌ 直接SCP到H:盘 不生效(Google Drive挂载同步冲突)\n✅ 正确流程:\nbash\n# Step 1: SCP to D: (local)\nscp -i ~/.ssh/id_ed25519_hermes \\\n /data/data/com.termux/files/home/canvas_relayout.canvas \\\n 'Sozo@100.83.112.84:D:\\hermes\\幸福-canvas.canvas'\n\n# Step 2: PowerShell copy D: → H:\nssh -i ~/.ssh/id_ed25519_hermes Sozo@100.83.112.84 \\\n 'powershell -Command \"Copy-Item D:\\hermes\\幸福-canvas.canvas H:\\My Drive\\Jakephone\\Obsidian Vault\\幸福-canvas.canvas -Force\"'\n\nThen refresh Obsidian vault.\n\n## 上传后验证 Pipeline(2026-04-25 新增)\n\n上传前必须跑 3 项检查:\n\n### 1. JSON 结构验证\npython\nimport json\nwith open('canvas_relayout.canvas') as f:\n d = json.load(f)\nnodes = d['nodes']; edges = d['edges']\nnids = {n['id'] for n in nodes}\nassert len(nids) == len(nodes), \"Duplicate node IDs\"\norphans = [n['id'] for n in nodes if n.get('parent') and n['parent'] not in nids]\nassert not orphans, f\"Orphan nodes: {orphans}\"\nfor e in edges:\n assert e.get('fromNode') in nids and e.get('toNode') in nids\n assert str(e.get('id','')).lstrip('-').isdigit(), f\"non-numeric edge id {e['id']}\"\nprint(f\"✅ {len(nodes)} nodes, {len(edges)} edges, 0 errors\")\n\n\n### 2. BOM 检测(首字节必须 = 123)\npython\nwith open('canvas_relayout.canvas', 'rb') as f:\n first = f.read(3)\nif first == b'\\xef\\xbb\\xbf':\n raise AssertionError(\"❌ UTF-8 BOM detected — will break Obsidian\")\nprint(f\"✅ BOM-free (first byte={f.read(1)[0]})\")\n\n\n### 3. Obsidian 在线检查\nbash\ncurl -s http://localhost:19734/v1/health\n# 期望: {\"status\":\"ok\",\"authenticated\":true}\n\n\n## 文件大小警告\n- 原始 282 节点:~50KB\n- 347 节点 + 径向布局:~85KB(65 个额外节点)\n- Obsidian 对超大 Canvas 的支持有限,上传后若打不开可能是文件过大\n\n## Limitations\n- No attempt to minimize edge crossings (NP-hard for general graphs)\n- All leaf nodes at same depth in tree still arranged by angular span, not tightly packed\n- Canvas viewport starts at y=0 top; centered at y=3000 means initial view may need scrolling\n- Collision detection: currently only checks same-depth nodes, not cross-depth\n”}