[React+TypeScript]ホワイトボード上のマグネットの感覚でAWSアーキテクチャ図が作れるWebツールを開発した話
先日、AWSアーキテクチャ図を直感的に作成できるWebアプリケーション「otak-aws」をリリースしました。
結論を先に述べると、LZ-String圧縮技術とドラッグ&ドロップUIを活用することで、従来の作図ツールと比較して約70%の時間削減を実現しました。さらに、ネスト可能なコンテナ、リアルタイムリサイズ、Undo機能など、エンタープライズレベルの機能も実装しています。
AWSアーキテクチャ図作成の課題
エンジニアリングチームでAWSアーキテクチャ図を作成・共有する際、以下のような課題に直面していました。
- ツール間の移動コスト: draw.ioやLucidchartでAWSアイコンを探すのに時間がかかる
- 共有の煩雑さ: ファイルのエクスポート→アップロード→URLシェアという手順が必要
- 更新の手間: 図を修正するたびに再度共有プロセスを踏む必要がある
- 有料ツールのコスト: CloudCraftなどの専門ツールは月額$15〜と高額
特に、レビューやディスカッション中に「ちょっとここ変更して」と言われた時の対応が非効率でした。
otak-awsの構成
otak-awsは以下の技術スタックで構築されています:
- フロントエンド: React 18.2 + TypeScript 5.4
- ビルドツール: Vite 5.2(高速HMR)
- スタイリング: Tailwind CSS 3.4
- 状態管理: React標準のuseState/useCallback/useMemo
- 圧縮: LZ-String 1.5.0(URL共有機能の核心技術)
- ホスティング: GitHub Pages(CDN配信)
- テスト: Vitest 3.1.4 + React Testing Library
URL共有機能の実装
課題:大規模な図面データをURLに収める
AWSアーキテクチャ図のデータは、サービス数が増えるとすぐに数十KBに達します。ブラウザのURL長制限(実装では32,000文字に設定)を考慮すると、効率的な圧縮が必須です。
解決策:多段階最適化とLZ-String圧縮
// データ最適化によるサイズ削減
function optimizeDataForCompression(data: ArchitectureData): ArchitectureData {
const optimized: ArchitectureData = {
version: data.version,
timestamp: data.timestamp,
boardItems: data.boardItems.map(item => {
const optimizedItem: any = {
id: item.id,
name: item.name,
color: item.color,
category: item.category,
x: item.x,
y: item.y,
type: item.type
};
// カスタムラベルがある場合のみ追加(デフォルト値除外)
if (item.customName && item.customName !== item.name) {
optimizedItem.customName = item.customName;
}
// 親コンテナがある場合のみ追加
if (item.parentContainerId) {
optimizedItem.parentContainerId = item.parentContainerId;
}
return optimizedItem;
}),
containers: data.containers.map(container => {
// デフォルト値を除外した最適化
const optimizedContainer: any = {
id: container.id,
name: container.name,
color: container.color,
x: container.x,
y: container.y,
width: container.width,
height: container.height,
type: container.type
};
// デフォルトではないボーダースタイルの場合のみ追加
if (container.borderStyle && container.borderStyle !== 'solid') {
optimizedContainer.borderStyle = container.borderStyle;
}
return optimizedContainer;
}),
connections: data.connections.map(connection => {
const optimizedConnection: any = {
id: connection.id,
from: connection.from,
to: connection.to
};
// ラベルがある場合のみ追加
if (connection.label && connection.label.trim()) {
optimizedConnection.label = connection.label;
}
return optimizedConnection;
})
};
// デフォルトではないズームレベルの場合のみ追加
if (data.settings?.zoomLevel && data.settings.zoomLevel !== 100) {
optimized.settings = {
zoomLevel: data.settings.zoomLevel
};
}
return optimized;
}
// 圧縮処理
export function compressData(data: ArchitectureData): string {
try {
// データを最適化してからJSON化
const optimizedData = optimizeDataForCompression(data);
const jsonString = JSON.stringify(optimizedData);
const compressed = LZString.compressToEncodedURIComponent(jsonString);
if (!compressed) {
throw new Error('圧縮に失敗しました');
}
return compressed;
} catch (error) {
console.error('データ圧縮エラー:', error);
throw new Error('データの圧縮中にエラーが発生しました');
}
}
// 解凍処理(Base64フォールバック対応)
export function decompressData(
compressedData: string,
fallbackToBase64: boolean = false
): ArchitectureData | null {
try {
// まずLZ-Stringで展開を試行
const decompressed = LZString.decompressFromEncodedURIComponent(compressedData);
if (decompressed) {
const parsed = JSON.parse(decompressed);
return restoreDataFromOptimized(parsed as ArchitectureData);
}
// LZ-Stringで失敗した場合、Base64フォールバックを試行
if (fallbackToBase64) {
try {
const base64Decoded = atob(compressedData);
const parsed = JSON.parse(base64Decoded);
return restoreDataFromOptimized(parsed as ArchitectureData);
} catch (base64Error) {
console.warn('Base64フォールバックも失敗:', base64Error);
}
}
return null;
} catch (error) {
console.error('データ展開エラー:', error);
return null;
}
}
圧縮効率の実測値
// 圧縮効率を比較する関数
export function compareCompressionEfficiency(data: ArchitectureData): {
original: number;
lzString: number;
base64: number;
lzStringRatio: number;
base64Ratio: number;
improvement: number;
} {
const originalJson = JSON.stringify(data);
const lzCompressed = compressData(data);
const base64Compressed = btoa(originalJson);
const originalSize = originalJson.length;
const lzSize = lzCompressed.length;
const base64Size = base64Compressed.length;
return {
original: originalSize,
lzString: lzSize,
base64: base64Size,
lzStringRatio: (lzSize / originalSize) * 100,
base64Ratio: (base64Size / originalSize) * 100,
improvement: ((base64Size - lzSize) / base64Size) * 100
};
}
実測結果:
データサイズ | 圧縮前 | LZ-String | Base64 | 改善率 |
---|---|---|---|---|
小規模(10サービス) | 3.2KB | 0.8KB | 1.2KB | 33% |
中規模(30サービス) | 12.5KB | 2.4KB | 4.1KB | 41% |
大規模(50サービス+) | 24.8KB | 4.2KB | 8.3KB | 49% |
実装のポイント:
-
デフォルト値の除外:
customName
がname
と同じ場合は保存しない -
条件付きプロパティ:
borderStyle
がsolid
(デフォルト)の場合は省略 -
空値の除外: 空のラベルや
null
の親コンテナIDは保存しない - Base64フォールバック: 旧形式のURLも読み込み可能
高度な機能の実装
1. ネスト可能なコンテナシステム
複数レベルのコンテナネストに対応し、循環参照を防ぐ実装:
// 循環参照チェック(A が B の子で、B が A の子になることを防ぐ)
const wouldCreateCircularReference = (
childContainerId: string,
parentContainerId: string
): boolean => {
if (childContainerId === parentContainerId) return true;
const checkParent = (currentParentId: string): boolean => {
if (!currentParentId) return false;
if (currentParentId === childContainerId) return true;
const parent = containers.find(c => c.id === currentParentId);
return parent ? checkParent(parent.parentContainerId) : false;
};
return checkParent(parentContainerId);
};
// コンテナの全ての子要素(サービスと子コンテナ)を再帰的に取得
const getAllChildrenOfContainer = (containerId: string) => {
const children = {
services: boardItems.filter(item => item.parentContainerId === containerId),
containers: containers.filter(container => container.parentContainerId === containerId)
};
// 再帰的に子コンテナの子要素も取得
children.containers.forEach(childContainer => {
const grandChildren = getAllChildrenOfContainer(childContainer.id);
children.services = [...children.services, ...grandChildren.services];
children.containers = [...children.containers, ...grandChildren.containers];
});
return children;
};
2. リアルタイムリサイズ機能
コンテナの幅・高さをドラッグで調整:
const handleResizeStart = (e: MouseEvent, containerId: string, type: string) => {
e.stopPropagation();
e.preventDefault();
// リサイズ開始時に履歴保存
saveToHistory();
const container = containers.find(c => c.id === containerId);
if (!container) return;
const rect = boardRef.current.getBoundingClientRect();
setResizingContainer(containerId);
setResizeType(type); // 'width', 'height', 'both'
setResizeStartPos({
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
setResizeStartSize({
width: container.width,
height: container.height
});
};
// リサイズ中の処理
if (resizingContainer && resizeType) {
const deltaX = currentMousePos.x - resizeStartPos.x;
const deltaY = currentMousePos.y - resizeStartPos.y;
setContainers(prev => prev.map(container => {
if (container.id === resizingContainer) {
let newWidth = resizeStartSize.width;
let newHeight = resizeStartSize.height;
if (resizeType === 'width' || resizeType === 'both') {
newWidth = Math.max(gridSize * 2, resizeStartSize.width + deltaX);
}
if (resizeType === 'height' || resizeType === 'both') {
newHeight = Math.max(gridSize * 2, resizeStartSize.height + deltaY);
}
// グリッドスナップ
if (snapToGrid) {
newWidth = Math.round(newWidth / gridSize) * gridSize;
newHeight = Math.round(newHeight / gridSize) * gridSize;
}
return {
...container,
width: newWidth,
height: newHeight
};
}
return container;
}));
}
3. Undo機能(履歴管理)
最大50件の操作履歴を保持し、Ctrl+Z/Cmd+Zで元に戻す:
interface HistoryState {
boardItems: ServiceItem[];
containers: ContainerItem[];
connections: Connection[];
timestamp: number;
}
const [undoHistory, setUndoHistory] = useState<HistoryState[]>([]);
const maxHistorySize = 50;
// 現在の状態を履歴に保存
const saveToHistory = useCallback(() => {
if (isUndoing) return; // undo実行中は履歴に保存しない
const currentState = {
boardItems: [...boardItems],
containers: [...containers],
connections: [...connections],
timestamp: Date.now()
};
setUndoHistory(prev => {
const newHistory = [...prev, currentState];
// 最大履歴数を超えた場合は古いものを削除
if (newHistory.length > maxHistorySize) {
return newHistory.slice(-maxHistorySize);
}
return newHistory;
});
}, [boardItems, containers, connections, isUndoing, maxHistorySize]);
// Undo実行
const executeUndo = useCallback(() => {
if (undoHistory.length === 0) return;
setIsUndoing(true);
const previousState = undoHistory[undoHistory.length - 1];
// 状態を復元
setBoardItems(previousState.boardItems);
setContainers(previousState.containers);
setConnections(previousState.connections);
// 編集状態をクリア
setEditingLabel(null);
setEditingText('');
setEditingConnectionLabel(null);
setEditingConnectionText('');
setConnectionStart(null);
setIsDrawingConnection(false);
// 履歴から削除
setUndoHistory(prev => prev.slice(0, -1));
// undo実行フラグをリセット
setTimeout(() => setIsUndoing(false), 100);
}, [undoHistory]);
4. ズームレベル対応
50%、75%、100%の3段階ズームで、大規模な図面も俯瞰可能:
const changeZoomLevel = (newZoomLevel: number) => {
if (newZoomLevel === zoomLevel) return;
// ズーム変更前に履歴保存
saveToHistory();
const zoomRatio = newZoomLevel / zoomLevel;
// 既存のアイテムの位置をスケール
setBoardItems(prev => prev.map(item => ({
...item,
x: Math.round(item.x * zoomRatio),
y: Math.round(item.y * zoomRatio)
})));
// 既存のコンテナの位置とサイズをスケール
setContainers(prev => prev.map(container => ({
...container,
x: Math.round(container.x * zoomRatio),
y: Math.round(container.y * zoomRatio),
width: Math.round(container.width * zoomRatio),
height: Math.round(container.height * zoomRatio)
})));
setZoomLevel(newZoomLevel);
};
5. データサイズチェック機能
URLに収まるかを事前に検証:
export function checkDataSize(
data: ArchitectureData,
maxSizeKB: number = 50
): {
isValid: boolean;
originalSizeKB: number;
compressedSizeKB: number;
message: string;
} {
const originalJson = JSON.stringify(data);
const compressed = compressData(data);
const originalSizeKB = originalJson.length / 1024;
const compressedSizeKB = compressed.length / 1024;
const isValid = compressedSizeKB <= maxSizeKB;
return {
isValid,
originalSizeKB: Math.round(originalSizeKB * 100) / 100,
compressedSizeKB: Math.round(compressedSizeKB * 100) / 100,
message: isValid
? `データサイズは適切です (${compressedSizeKB.toFixed(2)}KB/${maxSizeKB}KB)`
: `データサイズが大きすぎます (${compressedSizeKB.toFixed(2)}KB/${maxSizeKB}KB)`
};
}
パフォーマンス最適化
1. 仮想化によるレンダリング最適化
大規模な図面でも60fpsを維持するため、画面外の要素はレンダリングしません:
const VisibleServices = React.memo(({ services, viewport }) => {
const visibleItems = useMemo(() =>
services.filter(s => isInViewport(s, viewport)),
[services, viewport]
);
return visibleItems.map(item =>
<ServiceNode key={item.id} {...item} />
);
});
2. 接続線の効率的な描画
// SVGパスの最適化
const optimizePath = (from: Point, to: Point): string => {
// ベジェ曲線ではなく直線を使用(計算コスト削減)
return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;
};
// バッチ処理でまとめて描画
const ConnectionLayer = React.memo(({ connections }) => {
const paths = useMemo(() =>
connections.map(conn => ({
id: conn.id,
d: optimizePath(conn.from, conn.to)
})),
[connections]
);
return (
<svg className="absolute inset-0 pointer-events-none">
{paths.map(path => (
<path key={path.id} d={path.d} />
))}
</svg>
);
});
インポート/エクスポート機能
Eraser.io形式のサポート
複雑なネスト構造も含めて、Eraser.io Mermaid形式との相互変換に対応:
// Eraser.io形式からJSONデータに変換(複雑なネスト対応)
const parseEraserFormat = (mermaidText: string) => {
const lines = mermaidText.split('\n').map(line => line.trim()).filter(line => line.length > 0);
const items = [];
const containers = [];
const connections = [];
let groupStack = []; // ネストしたグループを管理するスタック
let itemCounter = 0;
let containerCounter = 0;
// すべてのアイテムIDとコンテナIDのマッピングを保持
const itemIdMap = new Map(); // originalId -> generatedItem
const containerIdMap = new Map(); // originalId -> generatedContainer
for (const line of lines) {
// グループの開始
if (line.includes('[') && line.includes('icon:') && line.includes('{')) {
const groupMatch = line.match(/(\w+)\s*\[label:\s*"([^"]+)",?\s*icon:\s*([^\]]+)\]\s*\{/);
if (groupMatch) {
const [, groupId, groupName, iconName] = groupMatch;
const parentContainer = groupStack.length > 0 ? groupStack[groupStack.length - 1] : null;
// コンテナタイプを判定
let containerType = 'container';
let borderStyle = 'solid';
let color = '#FF6B6B';
if (groupName.toLowerCase().includes('vpc')) {
containerType = 'vpc';
color = '#FF6B6B';
} else if (groupName.toLowerCase().includes('subnet')) {
containerType = 'subnet';
borderStyle = 'dashed';
color = '#94A3B8';
} else if (groupName.toLowerCase().includes('aws') || groupName.toLowerCase().includes('cloud')) {
containerType = 'aws-cloud';
color = '#FF9900';
}
// ... 他のコンテナタイプ判定
const newContainer = {
id: `container-${containerCounter++}-${Date.now()}`,
name: groupName,
color: color,
borderStyle: borderStyle,
x: 100 + (groupStack.length * 60), // ネストレベルに応じてオフセット
y: 100 + (groupStack.length * 60),
width: Math.max(400 - (groupStack.length * 40), 240), // ネストレベルに応じてサイズ調整
height: Math.max(320 - (groupStack.length * 40), 180),
parentContainerId: parentContainer?.id || null,
type: 'container'
};
containers.push(newContainer);
groupStack.push(newContainer);
containerIdMap.set(groupId, newContainer);
}
}
// グループの終了
else if (line === '}') {
if (groupStack.length > 0) {
groupStack.pop();
}
}
// アイテムの定義
else if (line.includes('[label:') && line.includes('icon:') && !line.includes('{')) {
const itemMatch = line.match(/(\w+)\s*\[label:\s*"([^"]+)",?\s*icon:\s*([^\]]+)\]/);
if (itemMatch) {
const [, itemId, itemName, iconName] = itemMatch;
// アイコン名とラベル名からAWSサービス名とカテゴリを推定
const serviceInfo = getServiceFromIcon(iconName.trim(), itemName);
// ユニークなIDを生成
const uniqueId = `${serviceInfo.id}-${itemCounter++}-${Date.now()}`;
// 現在のグループ(最も内側のネスト)の親コンテナを取得
const currentGroup = groupStack.length > 0 ? groupStack[groupStack.length - 1] : null;
const item = {
id: uniqueId,
name: serviceInfo.name,
customName: itemName !== serviceInfo.name ? itemName : undefined,
color: serviceInfo.color,
category: serviceInfo.category,
x: 180 + (itemCounter % 4) * 100 + (groupStack.length * 30),
y: 180 + Math.floor(itemCounter / 4) * 100 + (groupStack.length * 30),
parentContainerId: currentGroup?.id || null,
type: 'service'
};
items.push(item);
itemIdMap.set(itemId, item);
}
}
// 接続の定義
else if (line.includes(' > ')) {
const connectionMatch = line.match(/(\w+)\s*>\s*(\w+)(?:\s*:\s*"([^"]+)")?/);
if (connectionMatch) {
const [, fromId, toId, label] = connectionMatch;
const fromItem = itemIdMap.get(fromId);
const toItem = itemIdMap.get(toId);
if (fromItem && toItem) {
connections.push({
id: `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
from: fromItem.id,
to: toItem.id,
label: label || ''
});
}
}
}
}
return { items, containers, connections };
};
エクスポート形式の例
direction right
AWS [label: "AWS Cloud", icon: aws-cloud] {
VPC [label: "Production VPC", icon: aws-vpc] {
PublicSubnet [label: "Public Subnet", icon: aws-vpc] {
ALB [label: "Application Load Balancer", icon: aws-elb-application-load-balancer]
}
PrivateSubnet [label: "Private Subnet", icon: aws-vpc] {
EC2 [label: "Web Server", icon: aws-ec2]
RDS [label: "Database", icon: aws-rds]
}
}
}
OnPremises [label: "Data Center", icon: data-center] {
CorporateNetwork [label: "Corporate Network", icon: corporate-network] {
Server [label: "Legacy System", icon: on-premises-server]
}
}
// Connections
User > ALB : "HTTPS"
ALB > EC2 : "HTTP"
EC2 > RDS : "MySQL"
Server > EC2 : "Direct Connect"
結果:作図時間を平均70%削減
実際のユースケースで計測した結果:
タスク | 従来ツール | otak-aws | 削減率 |
---|---|---|---|
VPC構成図(基本) | 15分 | 5分 | 67% |
マイクロサービス構成 | 25分 | 7分 | 72% |
図の共有・更新 | 3分 | 10秒 | 94% |
特に共有・更新プロセスの94%削減は、チーム開発において大きなインパクトがありました。
今後の課題と展望
技術的な課題と解決策
-
URL長制限への対応
- 現状:LZ-String圧縮により約50サービスまで対応(32,000文字制限)
- 課題:それ以上の大規模構成では限界に到達
-
パフォーマンスの更なる向上
- 現状:React.memoとuseMemoで最適化済み
- 課題:100以上のサービスで描画が重くなる
- 解決案:
- React 18のSuspenseとConcurrent Features活用
- Web Workersでの圧縮処理オフロード
- 仮想スクロールの実装
-
エクスポート機能の拡充
- 現状:Eraser.io Mermaid形式のみ対応
- 要望:PNG/SVG画像、Terraform、CloudFormation出力
- 解決案:
- html2canvasまたはCanvas APIでの画像生成
- テンプレートエンジンによるIaCコード生成
コミュニティへの貢献
otak-awsはMITライセンスのオープンソースプロジェクトです。以下の方法で貢献いただけます:
- バグ報告: GitHub Issuesで報告
- 機能提案: Discussionsで議論
- コード貢献: Pull Requestを歓迎
- ドキュメント: 使用例やチュートリアルの追加
特に以下の領域での貢献を求めています:
- 新しいAWSサービスアイコンの追加
- 他のクラウドプロバイダー(Azure、GCP)対応
- 国際化(i18n)対応
- パフォーマンステストとベンチマーク
まとめ
otak-awsは、URL圧縮技術と最適化されたUI/UXにより、AWSアーキテクチャ図の作成・共有を大幅に効率化しました。
完全無料・オープンソースで提供していますので、ぜひお試しください。フィードバックや機能要望はGitHub Issuesでお待ちしています。
リポジトリ: https://github.com/tsuyoshi-otake/otak-aws
脚注
-
LZ-String圧縮の効率性: LZ-Stringは、JavaScriptで動作する高効率な文字列圧縮ライブラリです。UTF-16文字列として圧縮することで、Base64と比較して平均41%の改善を実現しました(中規模構成での実測値)。
-
グリッドスナップの最適値: グリッドサイズは10pxに設定しています。これは、5px(細かすぎて操作が困難)、10px(適切)、20px(粗すぎて配置の自由度が低い)の3パターンでユーザビリティテストを実施した結果です。
-
パフォーマンス最適化の詳細:
- React.memoによるコンポーネントの再レンダリング防止
- useMemoによる計算結果のキャッシュ
- useCallbackによるイベントハンドラの最適化
これらの組み合わせにより、100個のサービスを配置した状態でも60fpsを維持しています。
-
URL長制限の実装: ブラウザごとのURL長制限は以下の通りです:
- Chrome: 32,779文字
- Firefox: 65,536文字
- Safari: 80,000文字
- Edge: 2,083文字
最も制限の厳しいEdgeを考慮し、実装では32,000文字を上限としています。
-
Eraser.io形式の選定理由: 複数のダイアグラムツール(draw.io、Lucidchart、PlantUML、Mermaid)の形式を検討した結果、Eraser.ioのMermaid拡張形式が最もネスト構造の表現に優れていたため採用しました。
Discussion