注意:この記事はAIが作成しています
参照元の存在、参考元のリンクの信頼度、事実の歪曲がないかをAIによりセルフチェックしています
ANTLR + ELK.js + D3.js + MonacoでPlantUMLライクな独自ワークフローシステムを構築する
はじめに
PlantUMLのようなテキストベースの図表記述言語は、バージョン管理との相性の良さやコード内でのドキュメント管理の容易さから多くの開発者に愛用されています。本記事では、ANTLR、ELK.js、D3.js、Monacoエディタを組み合わせて、独自のドメイン特化型ワークフロー記述言語とそのビジュアライザを構築する方法を解説します。
システム全体アーキテクチャ
技術スタックの選定理由
ANTLR(ANother Tool for Language Recognition)
- 強力な言語処理能力: 複雑な文法定義とエラー処理が可能
- マルチプラットフォーム対応: JVM版とTypeScript版の選択が可能
- IDE統合: 文法デバッグツールが充実
ELK.js(Eclipse Layout Kernel)
- 高度な自動レイアウト: 階層的なグラフレイアウトアルゴリズムを提供
- カスタマイズ性: ノード間隔、エッジルーティングなど詳細な制御が可能
- ブラウザ対応: Web Workerでの実行により、メインスレッドをブロックしない
D3.js
- 柔軟なSVG操作: データバインディングによる効率的な描画
- アニメーション: スムーズな遷移効果の実装が容易
- インタラクティブ性: ズーム、パン、ノード選択などの実装が可能
Monaco Editor
- VS Code相当の編集体験: シンタックスハイライト、自動補完、エラー表示
- 言語サービス統合: カスタム言語のサポートが容易
- 高パフォーマンス: 大規模ファイルの編集にも対応
実装手順
1. DSL(Domain Specific Language)の設計
まず、ワークフロー記述用のDSLを設計します。
// workflow.g4 - ANTLR文法定義
grammar Workflow;
workflow : 'workflow' ID '{' statement* '}' EOF ;
statement : nodeDecl | edgeDecl | propertyDecl ;
nodeDecl : 'node' ID nodeType? properties? ;
nodeType : ':' ('start' | 'end' | 'task' | 'decision') ;
edgeDecl : ID '->' ID label? condition? ;
label : '[' STRING ']' ;
condition : 'when' expression ;
properties : '{' property (',' property)* '}' ;
property : ID '=' value ;
// Lexer規則
ID : [a-zA-Z_][a-zA-Z0-9_]* ;
STRING : '"' (~["\r\n])* '"' ;
WS : [ \t\r\n]+ -> skip ;
2. パーサーの実装(TypeScript版)
// WorkflowParser.ts
import { CharStreams, CommonTokenStream } from 'antlr4ts';
import { WorkflowLexer } from './generated/WorkflowLexer';
import { WorkflowParser } from './generated/WorkflowParser';
import { WorkflowVisitor } from './WorkflowVisitor';
export class DSLParser {
parse(input: string): WorkflowGraph {
const inputStream = CharStreams.fromString(input);
const lexer = new WorkflowLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new WorkflowParser(tokenStream);
const tree = parser.workflow();
const visitor = new WorkflowVisitor();
return visitor.visit(tree);
}
}
3. ELK.jsによるレイアウト計算
// LayoutEngine.ts
import ELK from 'elkjs/lib/elk.bundled';
export class LayoutEngine {
private elk: ELK;
constructor() {
this.elk = new ELK();
}
async layout(graph: WorkflowGraph): Promise<LayoutedGraph> {
const elkGraph = {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
'elk.spacing.nodeNode': '50',
'elk.layered.spacing.edgeNodeBetweenLayers': '50'
},
children: graph.nodes.map(node => ({
id: node.id,
width: node.width || 150,
height: node.height || 50,
labels: [{ text: node.label }]
})),
edges: graph.edges.map(edge => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
labels: edge.label ? [{ text: edge.label }] : []
}))
};
return await this.elk.layout(elkGraph);
}
}
4. D3.jsによるレンダリング
// Renderer.ts
import * as d3 from 'd3';
export class WorkflowRenderer {
private svg: d3.Selection<SVGSVGElement, unknown, HTMLElement, any>;
constructor(container: HTMLElement) {
this.svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', '100%');
// ズーム機能の追加
const zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
this.svg.select('.graph-container')
.attr('transform', event.transform);
});
this.svg.call(zoom as any);
}
render(layoutedGraph: LayoutedGraph): void {
const container = this.svg.select('.graph-container')
|| this.svg.append('g').attr('class', 'graph-container');
// ノードの描画
const nodes = container.selectAll('.node')
.data(layoutedGraph.children, d => d.id);
const nodeEnter = nodes.enter()
.append('g')
.attr('class', 'node')
.attr('transform', d => `translate(${d.x}, ${d.y})`);
nodeEnter.append('rect')
.attr('width', d => d.width)
.attr('height', d => d.height)
.attr('rx', 5)
.style('fill', d => this.getNodeColor(d.type))
.style('stroke', '#333')
.style('stroke-width', 2);
nodeEnter.append('text')
.attr('x', d => d.width / 2)
.attr('y', d => d.height / 2)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.text(d => d.labels[0].text);
// エッジの描画
const edges = container.selectAll('.edge')
.data(layoutedGraph.edges, d => d.id);
const edgeEnter = edges.enter()
.append('g')
.attr('class', 'edge');
edgeEnter.append('path')
.attr('d', d => this.generatePath(d))
.style('fill', 'none')
.style('stroke', '#666')
.style('stroke-width', 2)
.attr('marker-end', 'url(#arrowhead)');
}
private generatePath(edge: any): string {
const path = d3.path();
const points = edge.sections[0].bendPoints || [];
path.moveTo(edge.sections[0].startPoint.x, edge.sections[0].startPoint.y);
points.forEach(point => path.lineTo(point.x, point.y));
path.lineTo(edge.sections[0].endPoint.x, edge.sections[0].endPoint.y);
return path.toString();
}
}
5. Monacoエディタの統合
// EditorSetup.ts
import * as monaco from 'monaco-editor';
export class WorkflowEditor {
private editor: monaco.editor.IStandaloneCodeEditor;
private parser: DSLParser;
private layoutEngine: LayoutEngine;
private renderer: WorkflowRenderer;
constructor(editorContainer: HTMLElement, previewContainer: HTMLElement) {
// カスタム言語の登録
monaco.languages.register({ id: 'workflow' });
// シンタックスハイライトの設定
monaco.languages.setMonarchTokensProvider('workflow', {
tokenizer: {
root: [
[/workflow|node|start|end|task|decision/, 'keyword'],
[/->/, 'operator'],
[/".*?"/, 'string'],
[/\b[a-zA-Z_]\w*\b/, 'identifier'],
[/[{}()\[\]]/, 'delimiter']
]
}
});
// エディタの初期化
this.editor = monaco.editor.create(editorContainer, {
value: this.getInitialContent(),
language: 'workflow',
theme: 'vs-dark',
automaticLayout: true,
minimap: { enabled: false }
});
// リアルタイムプレビューの設定
this.editor.onDidChangeModelContent(() => {
this.updatePreview();
});
this.parser = new DSLParser();
this.layoutEngine = new LayoutEngine();
this.renderer = new WorkflowRenderer(previewContainer);
}
private async updatePreview(): Promise<void> {
try {
const content = this.editor.getValue();
const graph = this.parser.parse(content);
const layouted = await this.layoutEngine.layout(graph);
this.renderer.render(layouted);
// エラーマーカーのクリア
monaco.editor.setModelMarkers(
this.editor.getModel()!,
'workflow',
[]
);
} catch (error) {
// エラーマーカーの表示
this.showError(error);
}
}
private showError(error: any): void {
const markers: monaco.editor.IMarkerData[] = [{
severity: monaco.MarkerSeverity.Error,
startLineNumber: error.line || 1,
startColumn: error.column || 1,
endLineNumber: error.line || 1,
endColumn: error.column + 10 || 10,
message: error.message
}];
monaco.editor.setModelMarkers(
this.editor.getModel()!,
'workflow',
markers
);
}
}
パフォーマンス最適化のポイント
1. Web Workerの活用
レイアウト計算を別スレッドで実行:
// layout.worker.ts
import ELK from 'elkjs/lib/elk.bundled';
const elk = new ELK();
self.addEventListener('message', async (e) => {
const graph = e.data;
const layouted = await elk.layout(graph);
self.postMessage(layouted);
});
2. 仮想化による大規模グラフ対応
ビューポート内のノードのみレンダリング:
class VirtualizedRenderer {
private viewport: { x: number, y: number, width: number, height: number };
renderVisible(nodes: Node[]): void {
const visibleNodes = nodes.filter(node =>
this.isInViewport(node)
);
// 可視ノードのみレンダリング
this.render(visibleNodes);
}
}
3. インクリメンタルパース
変更部分のみ再パース:
class IncrementalParser {
private lastAST: AST;
parseIncremental(content: string, changes: Change[]): AST {
// 変更箇所の特定と部分的な再パース
const affectedNodes = this.identifyAffectedNodes(changes);
return this.updateAST(this.lastAST, affectedNodes);
}
}
実装時の考慮事項
まとめ
ANTLR、ELK.js、D3.js、Monacoエディタを組み合わせることで、PlantUMLライクな高機能なワークフローシステムを構築できます。各技術の強みを活かすことで、以下の特徴を持つシステムが実現可能です:
- 優れた編集体験: Monacoによる高度な編集機能
- 柔軟な言語設計: ANTLRによる拡張可能な文法定義
- 美しいレイアウト: ELK.jsによる自動配置
- 豊かな表現力: D3.jsによるカスタマイズ可能な描画
このアーキテクチャは、組織固有のワークフロー記述要件に合わせてカスタマイズが可能で、チーム独自のドメイン言語を構築する基盤として活用できます。