第 32 課 - 文字轉圖表
在上一節,我們讓圖形之間的連接可以被表達,因此現在可以渲染許多類型的圖表。大型模型對多種以文字為基礎的圖表語言提供了很好的支援,例如 mermaid、D2 與 draw.io。
Mermaid
Excalidraw 提供 mermaid-to-excalidraw/api。如果對它的內部實作有興趣,可以閱讀官方文件:剖析器在底層如何運作?
簡而言之,Excalidraw 只支援有限的 Mermaid 圖表類型子集。它剖析 Mermaid 渲染器輸出的 SVG,轉換成內部的場景圖表示,並使用從剖析器取得的 Diagram JSON 來取得節點之間的關係。
流程圖
以下面最簡單的 Mermaid 流程圖為例:
flowchart LR
start-->stop首先使用 Mermaid 已棄用的 API 剖析文字,取出圖表類型、節點與邊的關係,但不含幾何資訊:
import mermaid, { MermaidConfig } from 'mermaid';
const diagram = await mermaid.mermaidAPI.getDiagramFromText(definition); // "flowchart LR..."接著用 Mermaid 的渲染方法,把 SVG 渲染到頁面上的隱藏容器。這也暴露了這個做法的限制:它只能在瀏覽器環境中執行。從渲染後的 SVG 輸出中取出節點與邊的幾何資訊,並使用上一步取得的節點與邊 ID:
const { svg } = await mermaid.render('mermaid-to-excalidraw', definition);最後,轉換成我們的畫布所接受的場景圖,節點上的文字會建立成獨立的子節點:
function convertFlowchartToSerializedNodes(
vertices: Map<string, Vertex>,
edges: Edge[],
options: { fontSize: number },
): SerializedNode[] {
vertices.forEach((vertex) => {
// Vertex
const serializedNode: SerializedNode = {
id: vertex.id,
type: 'rect',
x: vertex.x,
y: vertex.y,
width: vertex.width,
height: vertex.height,
stroke: 'black',
strokeWidth: 2,
};
// Label of vertex
const textSerializedNode: TextSerializedNode = {
parentId: vertex.id,
content: getText(vertex),
//...
};
});
// Edges
}只要替換圖形的型別,就能得到手繪風格的渲染:
nodes.forEach((node) => {
if (node.type === 'rect') {
node.type = 'rough-rect';
} else if (node.type === 'line') {
node.type = 'rough-line';
} else if (node.type === 'text') {
node.fontFamily = 'Gaegu';
}
});循序圖
[WIP] 狀態
D2
與 Mermaid 相比,D2 一開始就附帶方便的剖析器。
import { D2 } from '@terrastruct/d2';
const d2 = new D2();
const { diagram, graph } = await d2.compile(definition);
const { connections, shapes } = diagram;
const {
theme: { colors },
} = graph;x -> y: hello world連接會變成綁定到端點節點的 Path,帶有描邊與 fills: []。為開放路徑填色會隱含地把端點接合成一個區域,因此連接的顏色不能同時被拿來當填色。節點移動時連接仍然會更新,標記則畫出箭頭。
容器
good chips: {
doritos
ruffles
}
bad chips.lays
bad chips.pringles
chocolate.chip.cookies連接
dogs -> cats -> mice: chase
replica 1 <-> replica 2
a -> b: To err is human, to moo bovine {
source-arrowhead: 1
target-arrowhead: * {
shape: diamond
}
}drawio
可以使用社群剖析器:
import { parseDrawIO } from 'mxgraphdata';
const mxfile = await parseDrawIO(xml);
console.log(mxfile.diagram);邊標籤
邊上的文字標籤應該停留在沿著幾何妥善選好的位置;一般做法會在下一節 第 33 課 - 版面引擎 介紹。針對這個具體問題,比較 Excalidraw、tldraw 與 draw.io 的處理方式會有幫助。
獨立節點還是屬性
在 Excalidraw 中,把文字標籤放在邊(直線/箭頭)上,根本上並不是「讓文字沿著路徑走,或沿著曲線換行」。它依賴一種更簡單、也更可靠的做法:
- 標籤仍然是獨立的文字元素(不是線條本身的一部分來渲染)。
- 在資料中把文字綁定到線(箭頭/直線):文字記錄「它屬於哪一條線」,線則記錄「它的標籤文字 ID」或等價的關係。
- 用幾何計算為文字指定錨點:通常是線的「中點」,或一個 labelPosition 參數(0~1),再依照線的形狀(直線/折線/曲線)計算對應的點。
- 把文字視為被綁定的元素:當線移動、端點被拖曳、轉折改變或箭頭翻轉時,重新計算標籤位置並更新文字座標(同時處理與線重疊的迴避、偏移與對齊)。
tldraw 採取不同做法:標籤不是獨立的文字圖形,而是箭頭圖形本身的一個 prop(richText),並結合一套幾何定位與編輯互動。
在 draw.io 中,把文字標籤放在邊(連接器)上是它的核心能力之一。它的實作更接近傳統流程圖編輯器:「邊擁有自己的標籤(文字)功能,標籤作為邊的子狀態存在,位置透過幾何參數/偏移儲存」,而不是建立獨立的文字節點再綁定。
我們的實作
我們遵循 Excalidraw 風格的模型:邊上的文字仍然是獨立的文字節點,透過 parentId 附在折線上,並用 edgeLabelPosition(沿折線總弧長的 0–1)表示它位於邊上的哪裡。
export interface TextSerializedNode
extends BaseSerializeNode<'text'>,
Partial<TextAttributes>,
Partial<{
/**
* When set, this text is an edge label: parent should be a bound polyline/line.
* Value is 0–1 along total edge length (arc-length parameter).
*/
edgeLabelPosition: number;
}>;提升可讀性
邊標籤常常需要為了可讀性做調整,尤其是避免直接壓在描邊正上方。
背景填色
不透明的背景可以遮住底下的線。
- mxGraph(draw.io):
labelBackgroundColor樣式,例如labelBackgroundColor=white會在標籤後面加上白框。labelBorderColor可以加上邊框。 - tldraw:箭頭標籤沒有專用的背景選項,但文字圖形支援
showTextOutline,以提高對比。 - Excalidraw:作為獨立的文字元素,
backgroundColor會自動在文字後面畫一個填色矩形。
間距與內縮
垂直於線的偏移與內部留白,讓字形在實體上離開描邊。我們加入了 edgeLabelOffset 屬性;與 edgeLabelPosition 不同,它用絕對單位定義沿法線方向的偏移。
自動迴避
Excalidraw 與 tldraw 作為草圖風格的工具,避免對重疊做沉重的自動版面;使用者用手挪動標籤。mxGraph(draw.io)作為專業圖表編輯器,提供更細的重疊控制。
文字外框
這是完整背景之外較輕的替代:在字形周圍加上描邊或陰影以提高對比。tldraw 在啟用 showTextOutline 時,會在文字後面畫一層細外框,讓重疊時仍然可讀。
我們已經在 第 16 課 - 文字描邊 介紹過這個方法。
[WIP] 跳線
